Compare commits

...

10 Commits

Author SHA1 Message Date
nntrivi2001 c59e196e41 chore: v2.8.24 — sub-agent edits in Edited N files, OpenCode/Grok dropped, Artifacts list view and skill sync
Edited N files (#86): a turn's card counts the files its subagents wrote
or edited (read from their own transcripts, nested ones folded in), and a
turn longer than the chat's first page keeps the edits made before the
page cut. A non-Claude transcript page honours its limit again.

AI tools: OpenCode and Grok Build left the catalog (opencode-usage.js
gone; a Grok payload on the Claude hook route is dropped), and so did the
leftover Kimi Code quota reader.

Artifacts: a list view, skill packs and badges only by exception; a
shared skill follows its owner's folder, and an installer whose copy
differs is offered Update.
2026-10-26 09:46:45 +07:00
nntrivi2001 72aef2426c feat(artifacts): a shared skill follows its owner's folder; installers get Update
A skill in the Hub was a snapshot taken once: the owner changing
~/.claude/skills/<name> changed nothing there, and an installer had to
uninstall and install again to get a newer version.

- syncSkillSources (on GET /api/artifacts, at most every 15 s): a skill row
  taken from a folder — the owner's ~/.claude/skills/<name>, or the
  workspace's .claude/skills/<name> — is brought up to it. Only a folder
  whose names/sizes/mtimes changed is read again, with a pick's checks (no
  symlink, no secret, <= 5 MB, a member's files as theirs); a change is a
  NEW snapshot, the old one dropped, and an artifact_changed broadcast.
- artifacts.version (snapshot content hash) and source_hash (the folder's
  when last taken, additive columns): an upload or a Hub edit records the
  folder as it is then, so only a LATER folder change replaces it.
- A list row's updateAvailable: installed, and the viewer's copy differs
  from version. The page shows Update (a pack: Update N); install with
  {update: true} replaces the copies (old ones moved aside) and is not
  counted as another install.
2026-10-26 09:34:21 +07:00
nntrivi2001 244f999538 feat(artifacts): list view, skill packs, badges by exception
The page showed every artifact as a tall card with a blue Install and four
text actions, a Shared badge on nearly all of them and 12 cards per section,
so 26 artifacts filled ~1900px (5000px on a phone).

- List view by default (Grid kept, a per-browser choice): one row per
  artifact — kind icon, name, one line of description, owner and installs,
  ONE primary action, everything else in its ⋯ menu / right-click.
- Three or more skills of one owner sharing a name prefix (sg-*, vp-*) are
  one pack row: open it for its skills, or Install all / Install N more
  after one confirmation (a name already there is left alone).
- Badges only for the exception: Private (not shared), Hub only, Auto.
- No per-section paging: 30 entries, then Show more.
- Scope tabs under the title; category chips scroll on a phone.
- Another user's private row (seen by an admin) gets its own section
  instead of sitting under Shared by others.
- "1 installs" → "1 install".
2026-10-26 09:34:10 +07:00
nntrivi2001 127da516ac chore(quota): drop the Kimi Code quota reader (Kimi already left the catalog)
Redone on v2.8.22 from 3179d25 (feat/grok-usage, never merged).
2026-10-26 09:31:37 +07:00
nntrivi2001 c0832182f2 feat(ai-tools): drop OpenCode and Grok Build
- opencode.json / grok.json leave the built-in catalog; opencode-usage.js,
  its poller, transcript branch and table creation go with it (existing
  tables and session rows stay as history).
- A Grok payload reaching the Claude hook route (Grok runs the Claude hooks
  itself) is dropped instead of recorded as tool = 'grok'.
- quota.js no longer reads Grok or OpenCode Go logins.
- Redone on v2.8.22 from c6b2892 (feat/grok-usage, never merged).
2026-10-26 09:31:30 +07:00
nntrivi2001 9ea7758abb fix(transcript): a non-Claude page honours its limit again (#86)
`carry?.drop(messages.shift())` skips its argument when `carry` is null —
optional chaining short-circuits the whole call — so a Codex, omp, Cursor
or Antigravity page never shifted its sliding window and returned the
whole journal (codex-transcript.test.js: 8 messages for ?limit=2). Shift
first, then hand the dropped message to the carry.
2026-10-26 08:35:39 +07:00
nntrivi2001 f51a6a895e fix(chat): keep a long turn's Edited N files card whole across the page cut (#86)
The reported turn (/vp-auto, ~120 messages) showed "Edited 1 file": the
chat loads the newest 50 messages, and the turn's other Write
(rbac_routes_test.go, +176) was in the part before the page.

- turnCarry (agent-edits.js): every message the route's sliding window
  drops goes through it; it keeps the file changes since the last prompt it
  dropped (a dropped call whose result is on the page, and dropped
  subagents, included).
- A Claude page that starts mid-turn answers them as `turn_edits`; the
  chat keeps the one from the page that holds its oldest message and adds
  it to that turn's card.

Files a turn changed through Bash (python/sed writing a file) are still not
listed: nothing in the transcript says which files a command wrote.
2026-10-26 08:32:45 +07:00
nntrivi2001 5ccbaf5c23 fix(chat): count a subagent's edits in the turn's Edited N files card (#86)
A skill that hands its work to subagents (vp-auto) showed "Edited 1 file"
for a turn that changed 4: a subagent's Write/Edit calls are in its own
agent-<id>.jsonl, so the card only saw the parent's.

- server/lib/agent-edits.js reads a subagent's transcript (readLines, a
  member's only as theirs) for the files it wrote or edited with their +/-
  lines, nested subagents folded in (3 levels), cached by size + mtime.
- The transcript route puts them on each Claude Agent/Task result as
  `edits`; the card adds them to the turn's own edits. A file only a
  subagent wrote is listed, never an artifact card (its versions are
  rebuilt from this transcript's inputs, which a subagent's are not).
2026-10-26 08:32:45 +07:00
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
93 changed files with 4484 additions and 1864 deletions
+9 -9
View File
File diff suppressed because one or more lines are too long
Binary file not shown.

Before

Width:  |  Height:  |  Size: 411 KiB

After

Width:  |  Height:  |  Size: 465 KiB

+1 -1
View File
@@ -49,7 +49,7 @@
<g transform="translate(20,22)"><rect width="48" height="48" rx="12" fill="#4cc3f5" fill-opacity="0.12"/><rect x="11" y="11" width="11" height="11" rx="3" fill="#4cc3f5"/><rect x="26" y="11" width="11" height="11" rx="3" fill="none" stroke="#4cc3f5" stroke-width="2.5"/><rect x="11" y="26" width="11" height="11" rx="3" fill="none" stroke="#4cc3f5" stroke-width="2.5"/><rect x="26" y="26" width="11" height="11" rx="3" fill="#4cc3f5"/></g>
<text x="84" y="44" font-family="'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="21" font-weight="700" fill="#f1f5ff">Every AI coding agent</text>
<text x="84" y="72" font-family="'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="16" fill="#9aa6c4">Claude Code, Codex, Antigravity,</text>
<text x="84" y="94" font-family="'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="16" fill="#9aa6c4">Cursor, Kimi, Grok and more</text>
<text x="84" y="94" font-family="'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="16" fill="#9aa6c4">Cursor, omp and more</text>
</g>
<g transform="translate(792,300)">
<rect width="344" height="116" rx="16" fill="#ffffff" fill-opacity="0.045" stroke="#ffffff" stroke-opacity="0.10"/>

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

-3
View File
@@ -50,9 +50,6 @@ const QUOTA_TOOL_LABEL: Record<ToolQuota["id"], string> = {
codex: "Codex",
antigravity: "Antigravity",
cursor: "Cursor",
grok: "Grok",
kimi: "Kimi",
opencode: "OpenCode Go",
};
function localDate(d = new Date()): string {
+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 (
@@ -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,7 +78,12 @@ import { useSpin } from "../../hooks/useSpin";
import { useOnScreen, isShown } from "../../hooks/useOnScreen";
import { throttle } from "../../lib/throttle";
import { useTranslation } from "react-i18next";
import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/types";
import type {
TranscriptMessage,
TranscriptInfo,
TranscriptResult,
WSMessage,
} from "../../lib/types";
// Catch-up poll interval. Claude Code only fires hooks on PreToolUse /
// PostToolUse / Stop, which means a user-typed message (no hook) and any
@@ -175,6 +180,8 @@ function ConversationViewPane({
initialTranscriptId ?? null
);
const [hasMore, setHasMore] = useState(false);
// The edits of the turn the oldest loaded page starts inside of (`turn_edits`).
const [leadEdits, setLeadEdits] = useState<TranscriptResult["turn_edits"]>(undefined);
const [error, setError] = useState<string | null>(null);
const [transcripts, setTranscripts] = useState<TranscriptInfo[]>([]);
const [showNewMsg, setShowNewMsg] = useState(false);
@@ -283,6 +290,7 @@ function ConversationViewPane({
if (cancelled) return;
setNotStarted(false);
setMessages(result.messages);
setLeadEdits(result.turn_edits);
setTotal(result.total);
setHasMore(result.has_more);
lastLineRef.current = result.last_line;
@@ -343,6 +351,7 @@ function ConversationViewPane({
// Seed the view in a single render so the user sees the whole
// catch-up batch instead of a blank panel followed by a partial one.
setMessages(result.messages);
setLeadEdits(result.turn_edits);
firstLineRef.current = result.first_line;
setHasMore(result.has_more);
} else {
@@ -487,6 +496,7 @@ function ConversationViewPane({
firstLineRef.current = result.first_line;
setMessages((prev) => [...result.messages, ...prev]);
setLeadEdits(result.turn_edits);
setHasMore(result.has_more);
// Preserve scroll position (don't jump to top)
@@ -686,6 +696,7 @@ function ConversationViewPane({
stopped={stopped}
agentLinks={agentLinks}
compact={compact}
leadEdits={leadEdits}
sessionId={
selectedTranscript && selectedTranscript !== "main" ? undefined : sessionId
}
@@ -172,6 +172,9 @@ interface MessageListProps {
/** A subagent's conversation inside its step: tight spacing, and the task
* the orchestrator gave it reads as the prompt it is. */
compact?: boolean;
/** The files the first, cut-off turn changed before `messages` begins (the
* transcript page's `turn_edits`): counted in that turn's card. */
leadEdits?: TranscriptContent["edits"];
}
/** Build a map from tool_use id → tool_result for matching */
@@ -491,6 +494,10 @@ type Item =
| { kind: "command"; key: string; name: string | null; output: string | null; error: boolean };
const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
// A subagent's result carries the files IT changed (`edits`): they count too.
const AGENT_TOOLS = new Set(["Agent", "Task"]);
// The stand-in for the part of a turn the page cut off: its `edits` count.
const LEAD = "__lead_edits";
/** A line that is only Claude Code's local-command plumbing — the caveat it
* writes first, the command, what the command printed — or null for
@@ -515,11 +522,18 @@ export function localCommand(
return { name, output, error };
}
export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[] {
export function chatItems(
messages: TranscriptMessage[],
keys: string[],
leadEdits?: TranscriptContent["edits"]
): Item[] {
const items: Item[] = [];
// The file-changing calls of the turn so far, closed into one card when the
// human speaks again (or the list ends).
let edits: TranscriptContent[] = [];
// human speaks again (or the list ends) — starting with what the page's
// cut-off first turn changed before it.
let edits: TranscriptContent[] = leadEdits?.length
? [{ type: "tool_use", name: LEAD, edits: leadEdits }]
: [];
// The turn's prompt time, its newest assistant line and its output so far,
// for the "Done in …" line under a turn that ended.
let start: string | null = null;
@@ -592,7 +606,11 @@ export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[]
if (msg.usage) output.add(msg.usage);
msg.content.forEach((block, b) => {
const last = items[items.length - 1];
if (block.type === "tool_use" && EDIT_TOOLS.has(block.name ?? "")) edits.push(block);
if (
block.type === "tool_use" &&
(EDIT_TOOLS.has(block.name ?? "") || AGENT_TOOLS.has(block.name ?? ""))
)
edits.push(block);
// A question to the user is a turn of the conversation, not hidden work.
if (block.type === "tool_use" && block.name === "AskUserQuestion") {
items.push({ kind: "ask", key: `${key}:${b}`, block });
@@ -672,19 +690,38 @@ function EditedFilesCard({
const files = new Map<string, { added: number; removed: number; counted: boolean }>();
// Written whole in this turn (a Write): a component made here is an artifact.
const created = new Set<string>();
// Changed only by a subagent: listed, never an artifact card — the chat
// rebuilds an artifact from this transcript's Write/Edit inputs, which a
// subagent's are not.
const byAgent = new Set<string>();
for (const b of blocks) {
const r0 = b.id ? toolResultMap.get(b.id) : undefined;
if (AGENT_TOOLS.has(b.name ?? "") || b.name === LEAD) {
for (const e of (b.name === LEAD ? b.edits : r0?.edits) ?? []) {
if (!files.has(e.path)) byAgent.add(e.path);
const f = files.get(e.path) ?? { added: 0, removed: 0, counted: false };
f.added += e.added;
f.removed += e.removed;
f.counted ||= e.counted;
files.set(e.path, f);
}
continue;
}
const input = (b.input ?? {}) as Record<string, unknown>;
const p = input.file_path ?? input.notebook_path;
const r = b.id ? toolResultMap.get(b.id) : undefined;
if (typeof p !== "string" || !r || r.is_error) continue;
if (b.name === "Write") created.add(p);
byAgent.delete(p);
const f = files.get(p) ?? { added: 0, removed: 0, counted: false };
f.added += r.diff?.added ?? 0;
f.removed += r.diff?.removed ?? 0;
f.counted ||= !!r.diff;
files.set(p, f);
}
const artifacts = [...files.keys()].filter((p) => isArtifact(p, created.has(p)));
const artifacts = [...files.keys()].filter(
(p) => !byAgent.has(p) && isArtifact(p, created.has(p))
);
const cards = artifacts.length > 0 && (
<div data-testid="artifact-cards" className="flex flex-col gap-2">
{artifacts.map((p) => (
@@ -773,6 +810,7 @@ function MessageListView({
sessionId,
agentLinks,
compact = false,
leadEdits,
}: MessageListProps) {
const { t } = useTranslation("sessions");
const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]);
@@ -798,9 +836,10 @@ function MessageListView({
m.sender === "orchestrator" ? { ...m, sender: "user" as const } : m
)
: messages,
keys
keys,
leadEdits
),
[messages, keys, compact]
[messages, keys, compact, leadEdits]
);
const agents = useMemo(
() => (live ? backgroundAgents(messages, toolResultMap) : []),
@@ -4,7 +4,10 @@
* Desktop's "Edited N files" card: one row per file with the lines added and
* removed summed over the turn, a failed call left out, a row click handed
* to the caller, and a row (or the header, every file) dragged as `@path`
* attachments for a prompt box.
* attachments for a prompt box. Files a subagent changed (its Agent
* result's `edits`, read from its own transcript) are counted in the same
* card, never as an artifact; so are the files a turn changed before the
* loaded page began (`leadEdits`, the page's `turn_edits`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi } from "vitest";
@@ -127,4 +130,71 @@ describe("the Edited N files card", () => {
JSON.parse(all["application/x-agenthub-attach"]!).map((a: { path: string }) => a.path)
).toEqual(["@/w/a.ts", "@/w/b.json"]);
});
it("counts the files a subagent changed, beside the turn's own edits", () => {
render(
<MessageList
loading={false}
messages={[
user("run the task"),
call("1", "Edit", "/w/a.go"),
result("1", { added: 1, removed: 0 }),
{
type: "assistant",
sender: "assistant",
timestamp: at,
content: [{ type: "tool_use", id: "2", name: "Agent", input: { prompt: "do it" } }],
} as TranscriptMessage,
{
type: "user",
sender: "tool",
timestamp: at,
content: [
{
type: "tool_result",
id: "2",
output: "done",
agent: { id: "x1" },
edits: [
{ path: "/w/a.go", added: 4, removed: 2, counted: true },
{ path: "/w/b_test.go", added: 9, removed: 0, counted: true },
{ path: "/w/report.html", added: 5, removed: 0, counted: true },
],
},
],
} as TranscriptMessage,
user("thanks"),
]}
/>
);
const card = screen.getByTestId("edited-files");
expect(card.textContent).toContain("Edited 3 files");
expect(within(card).getByText("b_test.go")).toBeTruthy();
expect(within(card).getByTitle("/w/a.go").textContent).toContain("+5"); // a.go: 1 + 4
// A page only a subagent wrote is listed, not shown as an artifact card.
expect(within(card).getByText("report.html")).toBeTruthy();
expect(screen.queryByTestId("artifact-card")).toBeNull();
});
it("adds what the cut-off first turn changed before the page to that turn's card", () => {
render(
<MessageList
loading={false}
leadEdits={[{ path: "/w/rbac_test.go", added: 176, removed: 0, counted: true }]}
messages={[
call("1", "Write", "/w/handler_test.go"),
result("1", { added: 113, removed: 0 }),
user("what changed?"),
call("2", "Edit", "/w/x.go"),
result("2", { added: 1, removed: 0 }),
]}
/>
);
const [first, second] = screen.getAllByTestId("edited-files");
expect(first!.textContent).toContain("Edited 2 files");
expect(within(first!).getByText("rbac_test.go")).toBeTruthy();
expect(within(first!).getByText("handler_test.go")).toBeTruthy();
// The next turn's card is its own.
expect(second!.textContent).toContain("Edited 1 file");
});
});
+3 -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;
@@ -1882,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")}>
@@ -69,7 +69,7 @@ const TOOLS: AiToolsView = {
tool("codex", "Codex", { launcher: "codex", proxyCapable: true, prompt: null }),
tool("gemini", "Gemini"),
tool("aider", "Aider", { installed: false, path: null }),
tool("grok", "Grok", { enabled: false }),
tool("goose", "Goose", { enabled: false }),
],
};
+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>
);
}
@@ -65,7 +65,7 @@ const VIEW: AiToolsView = {
tools: [
tool("claude", "Claude Code"),
tool("gemini", "Gemini"),
tool("grok", "Grok", { enabled: false }),
tool("goose", "Goose", { enabled: false }),
tool("aider", "Aider", { installed: false, path: null }),
],
};
@@ -155,9 +155,9 @@ describe("AiToolsSettings", () => {
await user.click(within(gemini).getByText("Set default"));
expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { default: true });
// A disabled tool cannot become the default.
const grok = screen.getByTestId("ai-tool-grok");
expect(within(grok).getByText("Set default")).toBeDisabled();
expect(within(grok).getByRole("switch")).toHaveAttribute("aria-checked", "false");
const goose = screen.getByTestId("ai-tool-goose");
expect(within(goose).getByText("Set default")).toBeDisabled();
expect(within(goose).getByRole("switch")).toHaveAttribute("aria-checked", "false");
});
it("refreshes detection", async () => {
@@ -179,7 +179,7 @@ describe("AiToolsSettings", () => {
await user.click(screen.getByRole("menuitem", { name: "Disable" }));
expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { enabled: false });
// A disabled tool cannot become the default from the menu either.
await user.pointer({ keys: "[MouseRight]", target: screen.getByTestId("ai-tool-grok") });
await user.pointer({ keys: "[MouseRight]", target: screen.getByTestId("ai-tool-goose") });
expect(screen.getByRole("menuitem", { name: "Set default" })).toBeDisabled();
});
});
@@ -301,11 +301,9 @@ describe("AiToolsSettings — install and update", () => {
describe("AiToolIcon", () => {
it("retries the icon when the same instance shows another tool", async () => {
const { AiToolIcon } = await import("../../AiToolIcon");
const { container, rerender } = render(
<AiToolIcon tool={{ id: "opencode", label: "OpenCode" }} />
);
const { container, rerender } = render(<AiToolIcon tool={{ id: "aider", label: "Aider" }} />);
fireEvent.error(container.querySelector("img")!);
expect(screen.getByText("O")).toBeInTheDocument();
expect(screen.getByText("A")).toBeInTheDocument();
rerender(<AiToolIcon tool={{ id: "cursor", label: "Cursor CLI" }} />);
expect(container.querySelector('img[src="/api/ai-tools/cursor/icon"]')).toBeTruthy();
});
@@ -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");
});
});
+28 -2
View File
@@ -34,7 +34,6 @@
"download": "Download",
"noPreview": "This file type cannot be shown here — download it to open it.",
"install": "Install",
"installs": "{{count}} installs",
"installed": "Installed to {{path}}",
"installedPlugin": "Installed {{name}} — start a new session (or /reload-plugins) to use it",
"rename": "Edit",
@@ -197,5 +196,32 @@
"unowned.claimed": "Added to your library",
"unowned.declined": "Removed from your list",
"unowned.assigned": "Owner assigned",
"unowned.empty": "Nothing without an owner."
"unowned.empty": "Nothing without an owner.",
"installs_one": "{{count}} install",
"installs_other": "{{count}} installs",
"private": "Private",
"privateHint": "Not shared: only you (and admins) see it. Share puts it in the Hub.",
"showMore": "Show {{count}} more",
"view.label": "Layout",
"view.list": "List view",
"view.grid": "Grid view",
"pack.title": "{{prefix}}-* pack",
"pack.count_one": "{{count}} skill",
"pack.count_other": "{{count}} skills",
"pack.installAll": "Install all",
"pack.installRest": "Install {{count}} more",
"pack.installTitle": "Install {{count}} skills of {{prefix}}-*?",
"pack.installBody": "Each one is written into your own account. A skill you already have under the same name is left as it is:",
"pack.installed": "Installed {{ok}} · already there {{skipped}} · failed {{failed}}",
"section.unshared": "Not shared by their owners",
"section.unsharedHint": "Other users' private items — only admins see these",
"update": "Update",
"updateHint": "Its owner changed it since you installed it",
"updateTitle": "Update “{{title}}”?",
"updateBody": "Your copy of {{name}} is replaced by the newest version. The current one is moved to ~/.claude/agenthub-replaced-skills/, not deleted.",
"updated": "Updated {{name}}",
"pack.updateN": "Update {{count}}",
"pack.updateTitle": "Update {{count}} skills of {{prefix}}-*?",
"pack.updateBody": "Each copy is replaced by the newest version; the current ones are moved to ~/.claude/agenthub-replaced-skills/:",
"pack.updated": "Updated {{ok}} · failed {{failed}}"
}
+19 -1
View File
@@ -657,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"
}
+10
View File
@@ -1075,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.",
@@ -1134,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.",
+25 -1
View File
@@ -197,5 +197,29 @@
"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ủ."
"unowned.empty": "Không có mục nào chưa có chủ.",
"private": "Riêng tư",
"privateHint": "Chưa chia sẻ: chỉ bạn (và admin) thấy. Bấm Chia sẻ để đưa lên Hub.",
"showMore": "Xem thêm {{count}}",
"view.label": "Cách hiển thị",
"view.list": "Dạng danh sách",
"view.grid": "Dạng lưới",
"pack.title": "Bộ {{prefix}}-*",
"pack.count": "{{count}} skill",
"pack.installAll": "Cài tất cả",
"pack.installRest": "Cài {{count}} còn lại",
"pack.installTitle": "Cài {{count}} skill của bộ {{prefix}}-*?",
"pack.installBody": "Mỗi skill được ghi vào tài khoản của bạn. Skill nào bạn đã có cùng tên thì giữ nguyên:",
"pack.installed": "Đã cài {{ok}} · đã có sẵn {{skipped}} · lỗi {{failed}}",
"section.unshared": "Chủ chưa chia sẻ",
"section.unsharedHint": "Mục riêng tư của người khác — chỉ admin thấy",
"update": "Cập nhật",
"updateHint": "Người tạo đã sửa skill này từ khi bạn cài",
"updateTitle": "Cập nhật “{{title}}”?",
"updateBody": "Bản {{name}} của bạn sẽ được thay bằng phiên bản mới nhất. Bản hiện tại được chuyển sang ~/.claude/agenthub-replaced-skills/, không bị xóa.",
"updated": "Đã cập nhật {{name}}",
"pack.updateN": "Cập nhật {{count}}",
"pack.updateTitle": "Cập nhật {{count}} skill của bộ {{prefix}}-*?",
"pack.updateBody": "Mỗi bản sẽ được thay bằng phiên bản mới nhất; bản hiện tại được chuyển sang ~/.claude/agenthub-replaced-skills/:",
"pack.updated": "Đã cập nhật {{ok}} · lỗi {{failed}}"
}
+19 -1
View File
@@ -657,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"
}
+10
View File
@@ -1063,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.",
@@ -1122,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.",
@@ -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);
});
});
+32 -7
View File
@@ -895,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 {
@@ -907,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}` : ""}`;
}
@@ -1005,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;
}
@@ -3135,6 +3143,13 @@ export const api = {
method: "POST",
body: JSON.stringify({ replace }),
}),
/** POST /api/artifacts/:id/install `{update: true}` — an installed skill's
* copies replaced by its newest version (the old ones moved aside). */
updateInstalled: (id: number) =>
request<{ installed: string; replaced?: boolean }>(`/artifacts/${id}/install`, {
method: "POST",
body: JSON.stringify({ update: true }),
}),
/** POST /api/artifacts/:id/uninstall — a skill moved aside, a plugin `claude plugin uninstall`. */
uninstall: (id: number) =>
request<{ uninstalled: string; paths: string[] }>(`/artifacts/${id}/uninstall`, {
@@ -4174,6 +4189,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`, {
@@ -4408,6 +4430,9 @@ export interface Artifact {
inLibrary: boolean;
/** List rows only: a skill/plugin already in every account the viewer's sessions run as. */
installed?: boolean;
/** List rows only: an installed skill whose copy differs from its newest
* version (its owner changed it since) — the list offers Update. */
updateAvailable?: boolean;
/** file: captured when its session stopped, not pinned by hand. */
auto?: boolean;
/** Server mode: nobody owns it yet. */
+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 {
+12 -5
View File
@@ -732,10 +732,9 @@ export interface Session {
/** The AI coding CLI that produced the session: `"claude"` (the default —
* Claude Code's hooks and imported transcripts), `"codex"` (Codex's rollout
* journals and hooks), `"antigravity"` (agy's conversation files and
* hooks), `"cursor"` (Cursor's agent transcripts, no tokens), `"omp"`,
* `"opencode"` (their own session stores, with tokens and subagents) or
* `"grok"` (Grok running the Claude hooks, no tokens). Maps to
* `sessions.tool`. */
* hooks), `"cursor"` (Cursor's agent transcripts, no tokens) or `"omp"`
* (its own session journals, with tokens and subagents). Rows an older build
* wrote may still say `"opencode"` or `"grok"`. Maps to `sessions.tool`. */
tool?: string;
/** The workspace (lane id) that owns the session — the one whose pane held
* it; null for a session no workspace started.
@@ -1217,7 +1216,7 @@ export interface CostResult {
/** GET /api/pricing/quota: plan quota spent per native tool (server/lib/quota.js). */
export interface ToolQuota {
id: "claude" | "codex" | "antigravity" | "cursor" | "grok" | "kimi" | "opencode";
id: "claude" | "codex" | "antigravity" | "cursor";
/** One per plan window: `5h`, `wk`, `mo`, …; `usedPercent` is 0–100.
* `group` names the model pool a window belongs to (Antigravity's). */
windows: Array<{
@@ -3236,6 +3235,10 @@ export interface TranscriptContent {
/** Present on a Write/Edit/MultiEdit "tool_result": the lines it added and
* removed (from Claude Code's `structuredPatch`). */
diff?: { added: number; removed: number };
/** Present on an Agent/Task "tool_result" whose subagent (or one it started)
* wrote or edited files: each file with its +/- lines (`counted`: the
* results carried them). Read from the subagent's own transcript. */
edits?: { path: string; added: number; removed: number; counted: boolean }[];
/** Present on an AskUserQuestion "tool_result": question text → the option
* the user picked, or what they typed. */
answers?: Record<string, string>;
@@ -3320,6 +3323,10 @@ export interface TranscriptResult {
last_line: number;
/** JSONL line number of the first message in this page. */
first_line: number;
/** Claude only, when the page starts in the middle of a turn: the files
* that turn changed before the page's first message — the chat adds them
* to the turn's "Edited N files" card. */
turn_edits?: { path: string; added: number; removed: number; counted: boolean }[];
}
/** One entry in a session's transcript picker (main agent, a subagent, or a
+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);
}
File diff suppressed because it is too large Load Diff
+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>
+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}
+82 -11
View File
@@ -13,6 +13,10 @@
* the sandbox cannot show (Office, zip, PDF) offers Download only, the
* Add menu closes on Escape or a click outside it, the front page is shelved
* by kind until a category is picked, and a card opens its detail sheet.
* Rows carry one primary action; Preview, Share and Edit live in the ⋯ menu.
* A section shows 30 entries before "Show more", and three or more skills of
* one owner sharing a name prefix are one pack row that installs them all.
* An installed skill whose owner changed it shows Update (after a confirmation).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
@@ -33,6 +37,7 @@ vi.mock("../../lib/api", () => ({
update: vi.fn(),
remove: vi.fn(),
install: vi.fn(),
updateInstalled: vi.fn(),
uninstall: vi.fn(),
installs: vi.fn(),
setInstallsEnabled: vi.fn(),
@@ -113,6 +118,16 @@ const addMenu = async (user: ReturnType<typeof userEvent.setup>, label: string)
await user.click(screen.getByRole("button", { name: label }));
};
/** Run one item of the first (or the given) row's ⋯ menu. */
async function menuAction(
user: ReturnType<typeof userEvent.setup>,
name: string,
scope: HTMLElement = document.body
) {
await user.click(within(scope).getAllByRole("button", { name: "More actions" })[0]!);
await user.click(within(screen.getByTestId("artifact-menu")).getByRole("menuitem", { name }));
}
describe("Adding", () => {
it("adds the plugins picked from a marketplace link", async () => {
const user = userEvent.setup();
@@ -218,7 +233,11 @@ describe("Artifacts", () => {
const user = userEvent.setup();
open();
expect(await screen.findByRole("button", { name: "Hub" })).toBeInTheDocument();
expect(screen.getAllByRole("button", { name: /^Share$/ }).length).toBe(2);
await user.click(screen.getAllByRole("button", { name: "More actions" })[0]!);
expect(
within(screen.getByTestId("artifact-menu")).getByRole("menuitem", { name: "Share" })
).toBeInTheDocument();
await user.keyboard("{Escape}");
// One merged list by default; Hub is a filter over it.
expect(api.artifacts.list).toHaveBeenLastCalledWith(expect.objectContaining({ scope: "all" }));
await user.click(screen.getByRole("button", { name: "Hub" }));
@@ -271,20 +290,46 @@ describe("Artifacts", () => {
expect(within(screen.getByTestId("half-mine.own")).getByText("my-page")).toBeInTheDocument();
});
it("pages a long section 12 cards at a time", async () => {
it("shows a long section 30 entries at a time, then the rest on Show more", async () => {
vi.mocked(api.artifacts.list).mockResolvedValue({
items: Array.from({ length: 14 }, (_, i) =>
items: Array.from({ length: 34 }, (_, i) =>
item({ id: i + 1, kind: "page", title: `p${i + 1}`, files: [] })
),
});
const user = userEvent.setup();
open();
await screen.findByText("p1");
expect(screen.queryByText("p13")).toBeNull();
expect(screen.getByText("Showing 1-12 of 14")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Next" }));
expect(screen.getByText("p13")).toBeInTheDocument();
expect(screen.queryByText("p1")).toBeNull();
expect(screen.getByText("p30")).toBeInTheDocument();
expect(screen.queryByText("p31")).toBeNull();
await user.click(screen.getByRole("button", { name: "Show 4 more" }));
expect(screen.getByText("p34")).toBeInTheDocument();
expect(screen.getByText("p1")).toBeInTheDocument();
});
it("folds three skills sharing a name prefix into one pack that installs them all", async () => {
vi.mocked(api.artifacts.list).mockResolvedValue({
items: [
item({ id: 1, kind: "skill", title: "sg-qa", source: "sg-qa" }),
item({ id: 2, kind: "skill", title: "sg-ship", source: "sg-ship", installed: true }),
item({ id: 3, kind: "skill", title: "sg-fix", source: "sg-fix" }),
item({ id: 4, kind: "skill", title: "lone", source: "lone" }),
],
});
vi.mocked(api.artifacts.install).mockResolvedValue({ installed: "/h/x" } as never);
const user = userEvent.setup();
open();
const pack = await screen.findByTestId("pack-sg");
expect(within(pack).getByText("sg-* pack")).toBeInTheDocument();
expect(within(pack).getByText("3 skills")).toBeInTheDocument();
expect(screen.queryByTestId("artifact-1")).toBeNull();
expect(screen.getByTestId("artifact-4")).toBeInTheDocument();
await user.click(within(pack).getByRole("button", { expanded: false }));
expect(within(pack).getByTestId("artifact-1")).toBeInTheDocument();
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
await user.click(within(pack).getByRole("button", { name: "Install 2 more" }));
await waitFor(() => expect(api.artifacts.install).toHaveBeenCalledTimes(2));
expect(api.artifacts.install).toHaveBeenCalledWith(1);
expect(api.artifacts.install).toHaveBeenCalledWith(3);
});
it("lists who installed an artifact, when and where, from its install count", async () => {
@@ -333,12 +378,38 @@ describe("Artifacts", () => {
expect(within(card).getByText("Hub only")).toBeInTheDocument();
expect(within(card).queryByRole("button", { name: "Delete" })).toBeNull();
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
await user.click(within(card).getByRole("button", { name: "Remove from Hub" }));
await menuAction(user, "Remove from Hub", card);
await waitFor(() => expect(api.artifacts.update).toHaveBeenCalledWith(3, { shared: false }));
const calls = vi.mocked(confirmDialog).mock.calls;
expect(calls[calls.length - 1]?.[0].title).toMatch(/Remove “hub-only” from the Hub/);
});
it("offers Update for an installed skill its owner changed, after a confirmation", async () => {
vi.mocked(api.artifacts.list).mockResolvedValue({
items: [
item({
id: 2,
kind: "skill",
title: "deploy",
source: "deploy",
installed: true,
updateAvailable: true,
}),
],
});
vi.mocked(api.artifacts.updateInstalled).mockResolvedValue({ installed: "/h/x" });
const user = userEvent.setup();
open();
const card = await screen.findByTestId("artifact-2");
expect(within(card).queryByTestId("artifact-installed")).toBeNull();
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
await user.click(within(card).getByRole("button", { name: "Update" }));
expect(api.artifacts.updateInstalled).not.toHaveBeenCalled();
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
await user.click(within(card).getByRole("button", { name: "Update" }));
await waitFor(() => expect(api.artifacts.updateInstalled).toHaveBeenCalledWith(2));
});
it("shows Installed instead of Install for a skill already installed", async () => {
vi.mocked(api.artifacts.list).mockResolvedValue({
items: [item({ id: 2, kind: "skill", title: "deploy", source: "deploy", installed: true })],
@@ -436,7 +507,7 @@ describe("Artifacts", () => {
const user = userEvent.setup();
open();
await screen.findByText("standup");
await user.click(screen.getByRole("button", { name: "Preview" }));
await menuAction(user, "Preview");
expect(await screen.findByRole("heading", { name: "Daily standup" })).toBeInTheDocument();
expect(screen.getByTestId("skill-summary")).toHaveTextContent("/out/report.html");
expect(screen.queryByTestId("artifact-preview")).toBeNull();
@@ -464,7 +535,7 @@ describe("Artifacts", () => {
const user = userEvent.setup();
open();
await screen.findByText("standup");
await user.click(screen.getByRole("button", { name: "Edit" }));
await menuAction(user, "Edit");
const name = await screen.findByLabelText(/Skill name/);
expect(name).toHaveValue("standup");
expect(screen.getByLabelText(/Instructions/)).toHaveValue("Old body");
@@ -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.22 (rebuild client to match)
UI build v2.8.24 (rebuild client to match)
</span>
</p>
</div>
+37 -7
View File
File diff suppressed because one or more lines are too long
+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.
---
-1
View File
@@ -260,7 +260,6 @@ DASHBOARD_ANTIGRAVITY_POLL_MS=15000 # agy conversation files → Antigravity
AGENTHUB_GEMINI_DIR= # where agy keeps its data (default ~/.gemini)
DASHBOARD_CURSOR_POLL_MS=15000 # Cursor agent transcripts → Cursor sessions (no tokens; 60 s while idle)
DASHBOARD_OMP_POLL_MS=15000 # omp session journals → Oh My Pi sessions/tokens/subagents (60 s while idle)
DASHBOARD_OPENCODE_POLL_MS=15000 # opencode.db → OpenCode sessions/tokens/subagents (60 s while idle)
AGENTHUB_CURSOR_DIR= # where Cursor keeps its data (default ~/.cursor)
AGENTHUB_AGY_USAGE= # 0 never runs `agy --print /usage` for the Usage panel's Antigravity quota (default on; running agy may update it)
+3 -2
View File
File diff suppressed because one or more lines are too long
+4 -7
View File
@@ -310,13 +310,10 @@ per model and one agent per subagent the `task` tool spawned (its journal sits i
the folder named like the session's). An omp session can be resumed by id from
its workspace (`omp --resume <id>`), like a Claude or Codex one.
**OpenCode** sessions are read from its SQLite store
(`~/.local/share/opencode/opencode.db`, every `DASHBOARD_OPENCODE_POLL_MS` ms),
with their tokens and cost per model and one agent per subagent, and are
resumable by id (`opencode --session <id>`). **Grok Build** runs the Claude hooks in
`~/.claude/settings.json` itself, so its sessions arrive through AgentHub's own
hooks, recorded as Grok sessions (live status, no tokens); they resume with
`grok --resume <id>`. Cursor sessions resume by id too (`--resume <id>`).
Cursor sessions resume by id too (`--resume <id>`), and open as a chat like
omp ones. OpenCode and Grok Build are no longer AgentHub tools: a Grok Build
running the Claude hooks in `~/.claude/settings.json` is ignored, and sessions an
older build recorded for either stay in the history.
**Pipelines are dormant.** AgentHub ships no pipeline template, so a workspace has no
stages until you drop a template JSON into `DASHBOARD_PIPELINES_DIR` (see
+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.24
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.22",
"version": "2.8.24",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.8.22",
"version": "2.8.24",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.8.22",
"version": "2.8.24",
"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;
+1 -3
View File
@@ -51,14 +51,12 @@ const env = {
// No suite may fetch LiteLLM's price list (reset-pricing syncs at once).
AGENTHUB_PRICING_SYNC: "0",
// Nor run the developer's real agy (it may update itself) or send their
// real Cursor, Grok, Kimi or OpenCode login for the quota.
// real Cursor login for the quota.
AGENTHUB_AGY_USAGE: "0",
// Nor refresh (spend) a real Claude pool login: its refresh token is single use.
DASHBOARD_POOL_REFRESH_MS: "0",
XDG_CONFIG_HOME: tmuxDir,
XDG_DATA_HOME: tmuxDir,
GROK_HOME: tmuxDir,
KIMI_CODE_HOME: tmuxDir,
DASHBOARD_PIPELINES_DIR: path.join(dir, "fixtures", "pipelines"),
// Never this machine's server-mode S3 install: its system unit would become
// "the" service manager and its server.conf would send user changes to sudo.
+6 -17
View File
@@ -655,23 +655,12 @@ An `antigravity` workspace carries no pipeline either.
what it said so far; `GET /api/sessions/:id/transcript(s)` reads an omp
session's journals from there. Under S3 each member's `~/.omp` is read only
through `openSafeAsync`.
- **OpenCode sessions:** `server/lib/opencode-usage.js` reads OpenCode's
SQLite store (`~/.local/share/opencode/opencode.db`, under `XDG_DATA_HOME`
when set) every `DASHBOARD_OPENCODE_POLL_MS` ms (default 15000, `0`
disables), when its file or WAL changed: each root `session` row becomes a
`sessions` row (`tool = 'opencode'`), its tree's assistant `message` tokens
per `modelID` become `token_usage` (reasoning counted as output), and each
child session (the `task` tool's) an `opencode-<id>` subagent row.
`opencode_stores` holds each store's mtime and `opencode_sessions` each root
session's newest `time_updated`; the transcript routes read the conversation
(messages + parts) from the store itself. A member's store and WAL are copied
through the guard before they are opened.
- **Grok sessions:** Grok runs the hooks in `~/.claude/settings.json` (its
Claude compatibility), so AgentHub's own hook reaches `POST /api/hooks/event`
with no `tool`; `routes/hooks.js` tells it apart by the camelCase keys only
Grok sends (`hookEventName` + `workspaceRoot`), records it as `tool = 'grok'`
and drops its `transcript_path` (Grok's file, never parsed as a Claude
transcript). No tokens: Grok's session files are not read.
- **Grok hooks:** Grok is not an AgentHub tool, but it runs the hooks in
`~/.claude/settings.json` (its Claude compatibility), so AgentHub's own hook
can still reach `POST /api/hooks/event` with no `tool`; `routes/hooks.js`
tells it apart by the camelCase keys only Grok sends (`hookEventName` +
`workspaceRoot`) and drops it, rather than record a Claude session whose
`transcript_path` is Grok's file.
- **Quiet sessions:** agy fires no exit hook, so a session quiet for ten
minutes (with hook events) or two (without) is settled to `completed`, its
agents too, and sets its workspace idle. That workspace must be one the hooks left
+167
View File
@@ -0,0 +1,167 @@
/**
* @file Tests for server/lib/agent-edits.js (issue #86): the files a
* subagent wrote or edited are read from its own transcript — +/- lines from
* `structuredPatch` or a created file's content, a failed call left out, a
* nested subagent folded in — and put on the parent's Agent result as `edits`;
* and `turnCarry` hands a page of the chat the edits its cut-off first turn
* made before the page began.
* @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 fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const HOME = fs.mkdtempSync(path.join(os.tmpdir(), "agent-edits-"));
process.env.CLAUDE_HOME = HOME;
const { encodeCwd } = require("../lib/claude-home");
const { agentEdits, attachAgentEdits, turnCarry, _cache } = require("../lib/agent-edits");
const CWD = "/work/repo";
const SID = "11111111-2222-3333-4444-555555555555";
const subDir = path.join(HOME, "projects", encodeCwd(CWD), SID, "subagents");
const call = (id, name, input) => ({
type: "assistant",
message: { content: [{ type: "tool_use", id, name, input }] },
});
const result = (id, toolUseResult, isError = false) => ({
type: "user",
message: { content: [{ type: "tool_result", tool_use_id: id, is_error: isError }] },
toolUseResult,
});
const write = (agentId, lines) =>
fs.writeFileSync(
path.join(subDir, `agent-${agentId}.jsonl`),
lines.map((l) => JSON.stringify(l)).join("\n")
);
before(() => {
fs.mkdirSync(subDir, { recursive: true });
write("a1", [
call("t1", "Edit", { file_path: "/work/repo/main.go" }),
result("t1", { structuredPatch: [{ lines: [" x", "-old", "+new", "+more"] }] }),
call("t2", "Write", { file_path: "/work/repo/new_test.go" }),
result("t2", { type: "create", content: "a\nb\nc\n" }),
call("t3", "Edit", { file_path: "/work/repo/broken.go" }),
result("t3", "String to replace not found", true),
call("t4", "Agent", { prompt: "go deeper" }),
result("t4", { agentId: "a2", status: "completed" }),
]);
write("a2", [
call("u1", "Edit", { file_path: "/work/repo/main.go" }),
result("u1", { structuredPatch: [{ lines: ["+x"] }] }),
call("u2", "Bash", { command: "go test ./..." }),
result("u2", { stdout: "ok" }),
]);
});
after(() => fs.rmSync(HOME, { recursive: true, force: true }));
describe("agentEdits", () => {
it("lists a subagent's files with their lines, nested subagents folded in", async () => {
const edits = await agentEdits(SID, CWD, "a1");
assert.deepEqual(edits, [
{ path: "/work/repo/main.go", added: 3, removed: 1, counted: true },
{ path: "/work/repo/new_test.go", added: 3, removed: 0, counted: true },
]);
});
it("answers an empty list for a subagent with no transcript", async () => {
assert.deepEqual(await agentEdits(SID, CWD, "nope"), []);
});
it("reads a transcript again only after it changed", async () => {
await agentEdits(SID, CWD, "a2");
const file = path.join(subDir, "agent-a2.jsonl");
const before = _cache.get(file);
await agentEdits(SID, CWD, "a2");
assert.equal(_cache.get(file), before);
fs.appendFileSync(
file,
"\n" +
[
call("u3", "Write", { file_path: "/work/repo/late.go" }),
result("u3", { type: "create", content: "z\n" }),
]
.map((l) => JSON.stringify(l))
.join("\n")
);
const edits = await agentEdits(SID, CWD, "a2");
assert.ok(edits.some((e) => e.path === "/work/repo/late.go"));
});
});
describe("attachAgentEdits", () => {
it("puts edits on an Agent result only", async () => {
const messages = [
{
content: [
{ type: "tool_result", id: "t9", agent: { id: "a1" } },
{ type: "tool_result", id: "t8", diff: { added: 1, removed: 0 } },
{ type: "tool_result", id: "t7", agent: { id: "a1" }, is_error: true },
],
},
];
await attachAgentEdits(messages, SID, CWD);
const [ok, plain, failed] = messages[0].content;
assert.ok(ok.edits.some((e) => e.path === "/work/repo/new_test.go"));
assert.equal(plain.edits, undefined);
assert.equal(failed.edits, undefined);
});
});
describe("turnCarry", () => {
const prompt = (text) => ({ type: "user", sender: "user", content: [{ type: "text", text }] });
const use = (id, name, file_path) => ({
type: "assistant",
sender: "assistant",
content: [{ type: "tool_use", id, name, input: file_path ? { file_path } : {} }],
});
const res = (id, extra = {}) => ({
type: "user",
sender: "tool",
content: [{ type: "tool_result", id, ...extra }],
});
it("answers the files the cut-off turn changed before the page", async () => {
const c = turnCarry();
for (const m of [
prompt("old turn"),
use("o1", "Write", "/w/old.go"),
res("o1", { diff: { added: 9, removed: 0 } }),
prompt("this turn"),
use("1", "Write", "/w/a_test.go"),
res("1", { diff: { added: 176, removed: 0 } }),
use("2", "Edit", "/w/bad.go"),
res("2", { is_error: true }),
use("3", "Edit", "/w/b.go"),
])
c.drop(m);
// The page: the result of a dropped call, then more of the same turn.
const page = [res("3", { diff: { added: 2, removed: 1 } }), use("4", "Write", "/w/c.go")];
assert.deepEqual(await c.result(page, SID, CWD), [
{ path: "/w/a_test.go", added: 176, removed: 0, counted: true },
{ path: "/w/b.go", added: 2, removed: 1, counted: true },
]);
});
it("answers null when the page starts on a prompt", async () => {
const c = turnCarry();
c.drop(prompt("p"));
c.drop(use("1", "Write", "/w/a.go"));
c.drop(res("1", { diff: { added: 1, removed: 0 } }));
assert.equal(await c.result([prompt("next")], SID, CWD), null);
});
it("folds in a dropped subagent's own edits", async () => {
const c = turnCarry();
c.drop(prompt("p"));
c.drop(use("9", "Agent"));
c.drop(res("9", { agent: { id: "a1" } }));
const got = await c.result([use("10", "Bash")], SID, CWD);
assert.ok(got.some((e) => e.path === "/work/repo/new_test.go"));
});
});
+2 -2
View File
@@ -45,7 +45,7 @@ before(async () => {
builtin,
tool("gem", [
{ path: "~/.gem/settings.json" },
{ path: "~/.gem/opencode.jsonc" },
{ path: "~/.gem/tool.jsonc" },
{ path: "~/.gem/GEMINI.md" },
{ path: "~/.gem/missing.json" },
])
@@ -57,7 +57,7 @@ before(async () => {
fs.mkdirSync(path.join(home, ".gem"));
fs.writeFileSync(path.join(home, ".gem/settings.json"), '{"theme":"dark"}\n', { mode: 0o600 });
fs.writeFileSync(path.join(home, ".gem/opencode.jsonc"), '{\n // c\n "a": 1,\n}\n');
fs.writeFileSync(path.join(home, ".gem/tool.jsonc"), '{\n // c\n "a": 1,\n}\n');
fs.writeFileSync(path.join(home, ".gem/GEMINI.md"), "# rules\n");
const express = require("express");
+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)));
+31 -11
View File
@@ -26,8 +26,28 @@ for (const name of ["claude", "agy", "npm"]) {
const origPath = process.env.PATH;
process.env.PATH = BIN;
// An npm-installed tool, in the release-synced tier (only a release catalog may
// name an installer): no built-in tool updates through npm.
const REMOTE = path.join(ROOT, "remote-catalog");
fs.mkdirSync(REMOTE);
fs.writeFileSync(
path.join(REMOTE, "npmtool.json"),
JSON.stringify({
id: "npmtool",
label: "npm tool",
detect: ["npmtool"],
prompt: { mode: "flag", flag: "--prompt" },
install: { kind: "npm", package: "npmtool" },
update: { kind: "npm" },
})
);
const { createApp } = require("../index");
require("../lib/ai-tools").refresh();
require("../lib/ai-tools").__setCatalogDirs([
path.join(__dirname, "..", "data", "ai-tools"),
REMOTE,
path.join(ROOT, "custom-catalog"),
]);
let app;
let base;
@@ -59,7 +79,7 @@ describe("/api/ai-tools", () => {
assert.equal(agy.path, path.join(BIN, "agy"));
assert.equal(agy.resume, true);
assert.equal(agy.bypass, true);
assert.equal(body.tools.find((t) => t.id === "opencode").installed, false);
assert.equal(body.tools.find((t) => t.id === "npmtool").installed, false);
assert.equal("args" in agy, false);
});
@@ -76,7 +96,7 @@ describe("/api/ai-tools", () => {
it("answers ENOTOOL / EBADDEFAULT / EBADFIELD", async () => {
assert.equal((await request("PUT", "/api/ai-tools/nope", { enabled: false })).status, 404);
const bad = await request("PUT", "/api/ai-tools/opencode", { default: true });
const bad = await request("PUT", "/api/ai-tools/npmtool", { default: true });
assert.equal(bad.status, 400);
assert.equal(bad.body.error.code, "EBADDEFAULT");
assert.equal((await request("PUT", "/api/ai-tools/claude", { enabled: "no" })).status, 400);
@@ -112,10 +132,10 @@ describe("/api/ai-tools", () => {
});
it("refresh re-detects", async () => {
fs.writeFileSync(path.join(BIN, "opencode"), "#!/bin/sh\n");
fs.chmodSync(path.join(BIN, "opencode"), 0o755);
fs.writeFileSync(path.join(BIN, "npmtool"), "#!/bin/sh\n");
fs.chmodSync(path.join(BIN, "npmtool"), 0o755);
const r = await request("POST", "/api/ai-tools/refresh");
assert.equal(r.body.tools.find((t) => t.id === "opencode").installed, true);
assert.equal(r.body.tools.find((t) => t.id === "npmtool").installed, true);
});
it("/api/run/binary resolves through the catalog", async () => {
@@ -132,9 +152,9 @@ describe("lanes on catalog tools", () => {
fs.mkdirSync(d, { recursive: true });
return d;
};
let r = await request("POST", "/api/lanes", { cwd: dir("a"), title: "a", tool: "opencode" });
let r = await request("POST", "/api/lanes", { cwd: dir("a"), title: "a", tool: "npmtool" });
assert.equal(r.status, 201);
assert.equal(r.body.lane.tool, "opencode");
assert.equal(r.body.lane.tool, "npmtool");
r = await request("POST", "/api/lanes", { cwd: dir("b"), title: "b", tool: "omp" });
assert.equal(r.status, 201, "a tool that is not installed may still get a lane");
r = await request("POST", "/api/lanes", { cwd: dir("c"), title: "c", tool: "nope" });
@@ -212,8 +232,8 @@ describe("install / update / icons", () => {
const notInstalled = await request("POST", "/api/ai-tools/codex/update");
assert.equal(notInstalled.status, 409);
assert.equal(notInstalled.body.error.code, "ENOTINSTALLED");
// opencode was put on PATH by the refresh test, outside any npm prefix.
const notNpm = await request("POST", "/api/ai-tools/opencode/update");
// npmtool was put on PATH by the refresh test, outside any npm prefix.
const notNpm = await request("POST", "/api/ai-tools/npmtool/update");
assert.equal(notNpm.status, 409);
assert.equal(notNpm.body.error.code, "ENOTNPM");
const cross = await request("POST", "/api/ai-tools/codex/install", undefined, {
@@ -241,7 +261,7 @@ describe("install / update / icons", () => {
assert.deepEqual(spawned.at(-1), [path.join(BIN, "claude"), "update"]);
const v = await request("GET", "/api/ai-tools/versions");
assert.equal(v.status, 200);
assert.equal(v.body.opencode.npmManaged, false);
assert.equal(v.body.npmtool.npmManaged, false);
});
it("update-all answers ENOTHING when nothing has an update", async () => {
+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);
}
});
+67 -2
View File
@@ -10,8 +10,10 @@
* 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, same-origin writes, Claude Docs
* docs captured at their birth, documents captured at a session's stop, and
* unowned rows claimed, declined and assigned.
* docs captured at their birth, documents captured at a session's stop,
* unowned rows claimed, declined and assigned, and a skill that follows its
* source folder (a newer change wins over a Hub edit) with Update offered to
* an installed copy that differs.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -338,6 +340,69 @@ describe("skills", () => {
assert.equal(empty.status, 400);
});
it("follows its source folder and offers Update to an installed copy", async () => {
const src = path.join(ws, ".claude", "skills", "notes");
fs.mkdirSync(src, { recursive: true });
fs.writeFileSync(path.join(src, "SKILL.md"), "---\nname: notes\ndescription: v1\n---\nOne.\n");
const r = await request("POST", "/api/artifacts/skill", { laneId: lane.id, name: "notes" });
assert.equal(r.status, 201, r.text);
const id = r.body.item.id;
const listed = async () =>
(await request("GET", "/api/artifacts?kind=skill")).body.items.find((i) => i.id === id);
const raw = async () => (await request("GET", `/api/artifacts/${id}/raw?path=SKILL.md`)).text;
assert.equal((await request("POST", `/api/artifacts/${id}/install`, {})).status, 200);
let item = await listed();
assert.equal(item.installed, true);
assert.equal(item.updateAvailable, false);
const installs = item.installs;
// Unchanged folder: nothing taken.
assert.deepEqual(artifacts.syncSkillSources({ force: true }), []);
// The owner changes it: a new snapshot, and the installed copy is behind.
const snapBefore = db.prepare("SELECT snapshot FROM artifacts WHERE id = ?").get(id).snapshot;
fs.writeFileSync(path.join(src, "SKILL.md"), "---\nname: notes\ndescription: v2\n---\nTwo.\n");
fs.writeFileSync(path.join(src, "extra.md"), "more\n");
const changed = artifacts.syncSkillSources({ force: true });
assert.deepEqual(
changed.map((c) => c.id),
[id]
);
assert.match(await raw(), /Two\./);
assert.ok(!fs.existsSync(path.join(process.env.DASHBOARD_DATA_DIR, "artifacts", snapBefore)));
item = await listed();
assert.equal(item.description, "v2");
assert.ok(item.files.some((f) => f.path === "extra.md"));
assert.equal(item.updateAvailable, true);
// Update: the copy is replaced (old one moved aside), not counted again.
const up = await request("POST", `/api/artifacts/${id}/install`, { update: true });
assert.equal(up.status, 200, up.text);
const target = path.join(process.env.CLAUDE_HOME, "skills", "notes");
assert.match(fs.readFileSync(path.join(target, "SKILL.md"), "utf8"), /Two\./);
item = await listed();
assert.equal(item.updateAvailable, false);
assert.equal(item.installs, installs);
// A Hub edit is not undone by the unchanged folder; a newer folder change wins.
const edit = await request("PATCH", `/api/artifacts/${id}`, {
skill: { name: "notes", description: "hub", content: "Edited in the Hub." },
});
assert.equal(edit.status, 200, edit.text);
artifacts.syncSkillSources({ force: true });
assert.match(await raw(), /Edited in the Hub\./);
fs.writeFileSync(
path.join(src, "SKILL.md"),
"---\nname: notes\ndescription: v3\n---\nThree.\n"
);
artifacts.syncSkillSources({ force: true });
assert.match(await raw(), /Three\./);
// Update needs an installed copy.
await request("POST", `/api/artifacts/${id}/uninstall`, {});
const none = await request("POST", `/api/artifacts/${id}/install`, { update: true });
assert.equal(none.body.error.code, "ENOTINSTALLED");
});
it("refuses a skill holding a symlink or a secret-looking file", async () => {
const bad = path.join(ws, ".claude", "skills", "leaky");
fs.mkdirSync(bad, { recursive: true });
@@ -1,7 +1,7 @@
{
"id": "grok",
"label": "Grok",
"detect": ["grok"],
"id": "septool",
"label": "Separator tool",
"detect": ["septool"],
"prompt": {
"mode": "argv",
"separator": "--"
+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);
+9 -14
View File
@@ -2,10 +2,10 @@
* @file Tests POST /api/hooks/event for a hook Grok Build fired through the
* Claude hooks in `~/.claude/settings.json` (Grok's Claude compatibility): it
* arrives with no `tool`, is told apart by the camelCase keys only Grok sends
* (`hookEventName`, `workspaceRoot`), and lands as a `tool = 'grok'` session
* on the grok workspace — never the claude one sharing the directory — with
* its `transcript_path` dropped. A Claude hook is left Claude's. The DB lives
* in a temp dir.
* (`hookEventName`, `workspaceRoot`), and dropped — Grok is not an AgentHub
* tool, and taken as Claude's it would bind the claude workspace and name a
* Grok file as a Claude transcript. A Claude hook is left Claude's. The DB
* lives in a temp dir.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -58,23 +58,18 @@ const grokData = (id, extra = {}) => ({
});
describe("Grok hooks through the Claude settings", () => {
it("records a grok session, on the grok workspace, with no transcript path", async () => {
it("drops a Grok hook: no session, no event, no workspace bound", async () => {
const claudeLane = lanes.createLane({ title: "c", cwd: CWD, tool: "claude" });
const grokLane = lanes.createLane({ title: "g", cwd: CWD, tool: "grok" });
const id = "0199f0aa-1111-7222-8333-444455556666";
await post({ hook_type: "SessionStart", data: grokData(id) });
const r = await post({ hook_type: "SessionStart", data: grokData(id) });
assert.equal(r.ignored, true);
await post({
hook_type: "UserPromptSubmit",
data: grokData(id, { hookEventName: "user_prompt_submit", prompt: "fix the bug" }),
});
const s = db.prepare("SELECT * FROM sessions WHERE id = ?").get(id);
assert.equal(s.tool, "grok");
assert.equal(s.transcript_path, null);
const events = db.prepare("SELECT data FROM events WHERE session_id = ?").all(id);
assert.ok(events.length >= 1);
for (const e of events) assert.equal(JSON.parse(e.data).transcript_path, undefined);
assert.equal(db.prepare("SELECT * FROM sessions WHERE id = ?").get(id), undefined);
assert.equal(db.prepare("SELECT COUNT(*) n FROM events WHERE session_id = ?").get(id).n, 0);
assert.equal(lanes.getLane(claudeLane.id).session_id, null);
assert.equal(lanes.getLane(grokLane.id).session_id, id);
});
it("leaves a Claude hook Claude's", async () => {
+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"] });
-188
View File
@@ -1,188 +0,0 @@
/**
* @file Tests server/lib/opencode-usage.js and the OpenCode branch of the
* transcript routes — OpenCode sessions from its SQLite store: a root session
* created as `tool = 'opencode'` with its title, cwd, model and per-model
* tokens (its subagent session's included, reasoning counted as output), a
* subagent row per child session, an unchanged store never re-read, a
* session the user deleted staying deleted until it is updated, and the
* conversation (text, reasoning, tool call + result) in the Claude
* transcript shape. The store is a temp file with the tables and columns
* opencode 1.19 writes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const os = require("node:os");
const fs = require("node:fs");
const path = require("node:path");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-opencode-usage-"));
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
process.env.AGENTHUB_OPENCODE_DIR = path.join(ROOT, "opencode");
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const { db, Database } = require("../db");
const oc = require("../lib/opencode-usage");
const { createApp, startServer } = require("../index");
const ID = "ses_eac8393b4ffeEz26VGbeyq8sir";
const CHILD = "ses_eac8393b4fffChildSession01";
const CWD = path.join(fs.realpathSync(ROOT), "work");
const STORE = path.join(process.env.AGENTHUB_OPENCODE_DIR, "opencode.db");
const T0 = Date.parse("2026-10-19T09:27:05.000Z");
let server;
let BASE;
after(() => {
server?.close();
fs.rmSync(ROOT, { recursive: true, force: true });
});
function withStore(fn) {
const conn = new Database(STORE);
try {
fn(conn);
} finally {
conn.close();
}
}
const assistant = (model, tokens) => JSON.stringify({ role: "assistant", modelID: model, tokens });
const tokens = (input, output, reasoning = 0, read = 0, write = 0) => ({
input,
output,
reasoning,
cache: { read, write },
});
before(async () => {
fs.mkdirSync(path.dirname(STORE), { recursive: true });
withStore((c) => {
c.exec(`
CREATE TABLE session (id TEXT PRIMARY KEY, project_id TEXT NOT NULL, parent_id TEXT,
slug TEXT NOT NULL, directory TEXT NOT NULL, title TEXT NOT NULL, version TEXT NOT NULL,
model TEXT, time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, time_archived INTEGER);
CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL, data TEXT NOT NULL);
CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT NOT NULL, session_id TEXT NOT NULL,
time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, data TEXT NOT NULL);
`);
const s = c.prepare(
"INSERT INTO session (id, project_id, parent_id, slug, directory, title, version, model, time_created, time_updated) VALUES (?, 'global', ?, 'x', ?, ?, '1.19.3', ?, ?, ?)"
);
s.run(ID, null, CWD, "Fix the login bug", '{"id":"claude-sonnet-4-5","providerID":"anthropic"}', T0, T0 + 5000);
s.run(CHILD, ID, CWD, "Explore auth (@explore subagent)", null, T0 + 1000, T0 + 3000);
const m = c.prepare("INSERT INTO message VALUES (?, ?, ?, ?, ?)");
m.run("msg_1", ID, T0 + 10, T0 + 10, JSON.stringify({ role: "user" }));
m.run("msg_2", ID, T0 + 20, T0 + 20, assistant("claude-sonnet-4-5", tokens(100, 20, 5, 1000, 50)));
m.run("msg_3", CHILD, T0 + 1100, T0 + 1100, JSON.stringify({ role: "user" }));
m.run("msg_4", CHILD, T0 + 1200, T0 + 1200, assistant("claude-haiku-4-5", tokens(10, 4)));
const p = c.prepare("INSERT INTO part VALUES (?, ?, ?, ?, ?, ?)");
p.run("prt_1", "msg_1", ID, T0 + 11, T0 + 11, JSON.stringify({ type: "text", text: "Fix the login bug" }));
p.run("prt_2", "msg_2", ID, T0 + 21, T0 + 21, JSON.stringify({ type: "reasoning", text: "Look first" }));
p.run(
"prt_3",
"msg_2",
ID,
T0 + 22,
T0 + 22,
JSON.stringify({
type: "tool",
tool: "read",
callID: "call_1",
state: { status: "completed", input: { filePath: "auth.ts" }, output: "export {}" },
})
);
p.run("prt_4", "msg_2", ID, T0 + 23, T0 + 23, JSON.stringify({ type: "step-start" }));
p.run("prt_5", "msg_3", CHILD, T0 + 1101, T0 + 1101, JSON.stringify({ type: "text", text: "Explore auth" }));
p.run("prt_6", "msg_4", CHILD, T0 + 1201, T0 + 1201, JSON.stringify({ type: "text", text: "Found it" }));
});
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
const sessionRow = () => db.prepare("SELECT * FROM sessions WHERE id = ?").get(ID);
describe("opencode usage ingest", () => {
it("creates the root session with its title, cwd, model and its tree's tokens", async () => {
const r = await oc.ingestOpencodeUsage({ now: T0 + 600_000 });
assert.equal(r.files, 1);
const s = sessionRow();
assert.equal(s.tool, "opencode");
assert.equal(s.name, "Fix the login bug");
assert.equal(s.cwd, CWD);
assert.equal(s.model, "claude-sonnet-4-5");
assert.equal(s.status, "completed");
assert.equal(db.prepare("SELECT 1 FROM sessions WHERE id = ?").get(CHILD), undefined);
const usage = db
.prepare(
"SELECT model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens FROM token_usage WHERE session_id = ? ORDER BY model"
)
.all(ID);
assert.deepEqual(usage, [
{ model: "claude-haiku-4-5", input_tokens: 10, output_tokens: 4, cache_read_tokens: 0, cache_write_tokens: 0 },
{ model: "claude-sonnet-4-5", input_tokens: 100, output_tokens: 25, cache_read_tokens: 1000, cache_write_tokens: 50 },
]);
const agent = db.prepare("SELECT * FROM agents WHERE id = ?").get(`opencode-${CHILD}`);
assert.equal(agent.session_id, ID);
assert.equal(agent.type, "subagent");
assert.equal(agent.name, "Explore auth (@explore subagent)");
assert.equal(agent.status, "completed");
});
it("leaves an unchanged store unread", async () => {
const r = await oc.ingestOpencodeUsage({ now: T0 + 600_000 });
assert.equal(r.files, 0);
});
it("maps the store to the transcript shape, and lists the subagent's", async () => {
const list = await (await fetch(`${BASE}/api/sessions/${ID}/transcripts`)).json();
assert.deepEqual(
list.transcripts.map((t) => [t.id, t.type, t.db_agent_id]),
[
["main", "main", null],
[CHILD, "subagent", `opencode-${CHILD}`],
]
);
const body = await (await fetch(`${BASE}/api/sessions/${ID}/transcript`)).json();
assert.deepEqual(
body.messages.map((m) => [m.type, m.sender, m.content.map((c) => c.type).join("+")]),
[
["user", "user", "text"],
["assistant", "assistant", "thinking+tool_use"],
["user", "tool", "tool_result"],
]
);
assert.equal(body.messages[2].content[0].output, "export {}");
assert.equal(body.last_line, 3);
const tail = await (await fetch(`${BASE}/api/sessions/${ID}/transcript?after=2`)).json();
assert.equal(tail.messages.length, 1);
const sub = await (
await fetch(`${BASE}/api/sessions/${ID}/transcript?agent_id=${CHILD}`)
).json();
assert.deepEqual(
sub.messages.map((m) => [m.sender, m.content[0].text]),
[
["orchestrator", "Explore auth"],
["assistant", "Found it"],
]
);
});
it("keeps a deleted session deleted until OpenCode updates it", async () => {
db.prepare("DELETE FROM sessions WHERE id = ?").run(ID);
const bump = (t) => {
withStore((c) => c.prepare("UPDATE session SET title = title WHERE id = ?").run(ID));
const at = new Date(t);
fs.utimesSync(STORE, at, at);
};
bump(Date.now() + 5000);
await oc.ingestOpencodeUsage({ now: T0 + 600_000 });
assert.equal(sessionRow(), undefined);
withStore((c) => c.prepare("UPDATE session SET time_updated = ? WHERE id = ?").run(T0 + 9000, ID));
fs.utimesSync(STORE, new Date(Date.now() + 10_000), new Date(Date.now() + 10_000));
await oc.ingestOpencodeUsage({ now: T0 + 600_000 });
assert.equal(sessionRow()?.tool, "opencode");
});
});
+2 -2
View File
@@ -733,7 +733,7 @@ describe("pty-run", () => {
let savedPath;
before(() => {
fs.mkdirSync(BIN, { recursive: true });
for (const name of ["agy", "grok", "goose", "command-code", "omp"]) {
for (const name of ["agy", "septool", "goose", "command-code", "omp"]) {
fs.writeFileSync(path.join(BIN, name), "#!/bin/sh\n");
fs.chmodSync(path.join(BIN, name), 0o755);
}
@@ -854,7 +854,7 @@ describe("pty-run", () => {
);
assert.deepEqual(argvFor("antigravity"), [at("agy"), "-i", " -x y"]);
// Behind a `--` separator the prompt is already a positional, kept as typed.
assert.deepEqual(argvFor("grok"), [at("grok"), "--", "-x y"]);
assert.deepEqual(argvFor("septool"), [at("septool"), "--", "-x y"]);
assert.deepEqual(argvFor("command-code"), [at("command-code"), "--trust", " -x y"]);
assert.deepEqual(argvFor("antigravity", { bypass: false }), [at("agy"), "-i", " -x y"]);
});
+1 -178
View File
@@ -6,9 +6,7 @@
* with its own token only while it is valid, never refreshed, and never shown
* with the usage of a token that belongs to a different account; each
* tool's plan name; the Cursor CLI's login asked of cursor.com only while
* valid; Antigravity's `agy --print /usage` pools, never on an old agy; and
* the Grok, Kimi Code and OpenCode Go logins, each sent only to its own host
* while valid, never refreshed.
* valid; and Antigravity's `agy --print /usage` pools, never on an old agy.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -22,12 +20,6 @@ process.env.XDG_CONFIG_HOME = require("node:fs").mkdtempSync(
);
// Nor run the machine's real agy: only the Antigravity test's fake one runs.
process.env.AGENTHUB_AGY_USAGE = "0";
// Nor its real Grok, Kimi or OpenCode logins.
for (const k of ["GROK_HOME", "KIMI_CODE_HOME", "XDG_DATA_HOME"]) {
process.env[k] = require("node:fs").mkdtempSync(
require("node:path").join(require("node:os").tmpdir(), "quota-home-")
);
}
test("windowLabel names the plan windows", () => {
assert.strictEqual(windowLabel(300), "5h");
@@ -415,172 +407,3 @@ function stubFetch(answers) {
};
return { asked, restore: () => (global.fetch = prev) };
}
test("Grok: the CLI's own auth.x.ai session asks cli-chat-proxy for credits + plan, never past expiry", async () => {
const fs = require("node:fs");
const path = require("node:path");
const { quotas, __resetCache } = require("../lib/quota");
const auth = path.join(process.env.GROK_HOME, "auth.json");
const f = stubFetch({
"https://cli-chat-proxy.grok.com/v1/billing?format=credits": [
200,
{
config: {
creditUsagePercent: 12,
currentPeriod: { end: "2099-01-08T00:00:00Z" },
subscriptionTier: "SuperGrok",
monthlyLimit: { val: "200" },
used: { val: "50" },
billingPeriodEnd: "2099-02-01T00:00:00Z",
},
},
],
});
const grok = async () => (await quotas()).tools.find((t) => t.id === "grok");
try {
__resetCache();
assert.strictEqual(await grok(), undefined);
const later = new Date(Date.now() + 3600_000).toISOString();
fs.writeFileSync(
auth,
JSON.stringify({
"https://other.example": { key: "tok-other", expires_at: later },
"https://auth.x.ai": { key: "tok-x", user_id: "u1", email: "g@x.ai", expires_at: later },
})
);
__resetCache();
assert.deepStrictEqual(await grok(), {
id: "grok",
active: true,
account: "g@x.ai",
plan: "SuperGrok",
expiresAt: later,
windows: [
{ label: "wk", usedPercent: 12, resetsAt: "2099-01-08T00:00:00.000Z" },
{ label: "mo", usedPercent: 25, resetsAt: "2099-02-01T00:00:00.000Z" },
],
});
assert.deepStrictEqual(
f.asked.filter((a) => a.includes("grok.com")),
["https://cli-chat-proxy.grok.com/v1/billing?format=credits Bearer tok-x"]
);
fs.writeFileSync(
auth,
JSON.stringify({ "https://auth.x.ai": { key: "tok-x", expires_at: "2000-01-01T00:00:00Z" } })
);
__resetCache();
assert.strictEqual((await grok()).error, "ETOKENEXPIRED");
assert.strictEqual(f.asked.filter((a) => a.includes("grok.com")).length, 1);
} finally {
f.restore();
fs.rmSync(auth, { force: true });
__resetCache();
}
});
test("Kimi Code: the CLI's own token asks api.kimi.com/coding for 5h, week and month", async () => {
const fs = require("node:fs");
const path = require("node:path");
const { quotas, __resetCache } = require("../lib/quota");
const dir = path.join(process.env.KIMI_CODE_HOME, "credentials");
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, "kimi-code.json");
const f = stubFetch({
"https://api.kimi.com/coding/v1/usages": [
200,
{
usages: {
limit_5h: { used_ratio: 0.3, reset_time: "2099-01-01T05:00:00Z" },
limit_7d: { used_ratio: "0.2", reset_time: "2099-01-07T00:00:00Z" },
limit_month_total: { used_ratio: 0.4, reset_time: "2099-02-01T00:00:00Z" },
limit_month_code: { used_ratio: 0.25, reset_time: "2099-02-01T00:00:00Z" },
},
},
],
});
const kimi = async () => (await quotas()).tools.find((t) => t.id === "kimi");
try {
__resetCache();
assert.strictEqual(await kimi(), undefined);
const exp = Math.floor(Date.now() / 1000) + 600;
fs.writeFileSync(
file,
JSON.stringify({ access_token: "tok-k", refresh_token: "r", expires_at: exp })
);
__resetCache();
const row = await kimi();
assert.strictEqual(row.expiresAt, new Date(exp * 1000).toISOString());
assert.deepStrictEqual(
row.windows.map((w) => [w.label, Math.round(w.usedPercent)]),
[
["5h", 30],
["wk", 20],
["mo", 40],
]
);
assert.deepStrictEqual(
f.asked.filter((a) => a.includes("kimi")),
["https://api.kimi.com/coding/v1/usages Bearer tok-k"]
);
fs.writeFileSync(file, JSON.stringify({ access_token: "tok-k", expires_at: 1 }));
__resetCache();
assert.strictEqual((await kimi()).error, "ETOKENEXPIRED");
assert.strictEqual(f.asked.filter((a) => a.includes("kimi")).length, 1);
} finally {
f.restore();
fs.rmSync(file, { force: true });
__resetCache();
}
});
test("OpenCode Go: opencode's own `opencode-go` key asks opencode.ai for its meters", async () => {
const fs = require("node:fs");
const path = require("node:path");
const { quotas, __resetCache } = require("../lib/quota");
const dir = path.join(process.env.XDG_DATA_HOME, "opencode");
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, "auth.json");
const f = stubFetch({
"https://opencode.ai/zen/go/v1/usage": [
200,
{
usage: {
rolling: { percent: 10, resetsAt: "2099-01-01T05:00:00Z" },
weekly: { percent: 55.5, resetsAt: "2099-01-07T00:00:00Z" },
},
},
],
});
const go = async () => (await quotas()).tools.find((t) => t.id === "opencode");
try {
__resetCache();
// A login with no Go key (Copilot through opencode, say) is no row.
fs.writeFileSync(file, JSON.stringify({ "github-copilot": { type: "oauth", access: "a" } }));
assert.strictEqual(await go(), undefined);
fs.writeFileSync(
file,
JSON.stringify({
"opencode-go": { type: "api", key: "sk-go" },
other: { type: "api", key: "x" },
})
);
__resetCache();
assert.deepStrictEqual(await go(), {
id: "opencode",
active: true,
plan: "Go",
windows: [
{ label: "5h", usedPercent: 10, resetsAt: "2099-01-01T05:00:00.000Z" },
{ label: "wk", usedPercent: 55.5, resetsAt: "2099-01-07T00:00:00.000Z" },
],
});
assert.deepStrictEqual(
f.asked.filter((a) => a.includes("opencode")),
["https://opencode.ai/zen/go/v1/usage Bearer sk-go"]
);
} finally {
f.restore();
fs.rmSync(file, { force: true });
__resetCache();
}
});
+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",
+7 -44
View File
@@ -1,7 +1,7 @@
/**
* @file server-s3-quota.test.js
* @description S3 plan, P4: plan quota per member home. `quotas({home})`
* reads Claude, Codex, Cursor, Grok, Kimi Code and OpenCode Go from that
* reads Claude, Codex and Cursor from that
* Linux account's home only — never the host's logins, never agy — and only
* files the member owns (review fix F3: a FIFO or another account's file is
* not read). `GET /api/pricing/quota` gives a developer or an analyst their
@@ -32,16 +32,12 @@ process.env.CLAUDE_HOME = path.join(HOST, ".claude");
process.env.CODEX_HOME = path.join(HOST, ".codex");
process.env.XDG_CONFIG_HOME = path.join(HOST, ".config");
process.env.AGENTHUB_CURSOR_DIR = path.join(HOST, ".cursor");
process.env.GROK_HOME = path.join(HOST, ".grok");
process.env.KIMI_CODE_HOME = path.join(HOST, ".kimi-code");
process.env.XDG_DATA_HOME = path.join(HOST, ".local", "share");
const { createApp, startServer } = require("../index");
const auth = require("../lib/auth");
const linux = require("../lib/linux-accounts");
const runAs = require("../lib/run-as");
const { quotas, __resetCache } = require("../lib/quota");
const { Database } = require("../db");
const LATER = Date.now() + 3600_000;
const write = (file, body) => {
@@ -76,26 +72,6 @@ function seedHome(home, who, { codexPercent }) {
write(path.join(home, ".cursor", "cli-config.json"), {
authInfo: { email: `${who}@cursor`, userId: "u1" },
});
write(path.join(home, ".grok", "auth.json"), {
"https://auth.x.ai": { key: `${who}-grok`, expires_at: new Date(LATER).toISOString() },
});
write(path.join(home, ".kimi-code", "credentials", "kimi-code.json"), {
access_token: `${who}-kimi`,
expires_at: Math.floor(LATER / 1000),
});
}
/** OpenCode 2 keeps its key in `opencode.db` (WAL), not `auth.json`. */
function seedOpenCodeDb(home, key) {
const dir = path.join(home, ".local", "share", "opencode");
fs.mkdirSync(dir, { recursive: true });
const db = new Database(path.join(dir, "opencode.db"));
db.pragma?.("journal_mode = WAL");
db.exec(
"CREATE TABLE credential (integration_id TEXT, value TEXT, active INT, time_created INT)"
);
db.prepare("INSERT INTO credential VALUES ('opencode-go', ?, 1, 1)").run(JSON.stringify({ key }));
db.close();
}
let asked = [];
@@ -125,11 +101,7 @@ const setUid = (uid) => {
before(async () => {
seedHome(HOST, "host", { codexPercent: 77 });
write(path.join(HOST, ".local", "share", "opencode", "auth.json"), {
"opencode-go": { type: "api", key: "host-opencode" },
});
seedHome(MIA, "mia", { codexPercent: 21 });
seedOpenCodeDb(MIA, "mia-opencode");
setUid(process.getuid());
fs.writeFileSync(
process.env.AGENTHUB_SERVER_CONF,
@@ -159,27 +131,16 @@ describe("quotas({home}) reads a member's own logins", () => {
__resetCache();
asked = [];
const { tools } = await quotas({ home: MIA });
assert.deepEqual(tools.map((t) => t.id).sort(), [
"claude",
"codex",
"cursor",
"grok",
"kimi",
"opencode",
]);
assert.deepEqual(tools.map((t) => t.id).sort(), ["claude", "codex", "cursor"]);
const codex = tools.find((t) => t.id === "codex");
assert.equal(codex.account, "mia@codex");
assert.equal(codex.windows[0].usedPercent, 21);
assert.equal(tools.find((t) => t.id === "cursor").account, "mia@cursor");
assert.ok(asked.length >= 6, asked.join(","));
assert.ok(asked.length >= 3, asked.join(","));
assert.ok(
asked.every((a) => a.includes("mia-")),
`only mia's tokens leave: ${asked.join(",")}`
);
assert.ok(
asked.some((a) => a.includes("mia-opencode")),
"OpenCode 2's db is read"
);
assert.ok(tools.every((t) => t.label === undefined || t.label === null));
});
@@ -195,8 +156,10 @@ describe("quotas({home}) reads a member's own logins", () => {
});
it("reads no file the member does not own, and never blocks on a FIFO", async () => {
fs.mkdirSync(path.join(BOB, ".grok"), { recursive: true });
require("node:child_process").execFileSync("mkfifo", [path.join(BOB, ".grok", "auth.json")]);
fs.mkdirSync(path.join(BOB, ".config", "cursor"), { recursive: true });
require("node:child_process").execFileSync("mkfifo", [
path.join(BOB, ".config", "cursor", "auth.json"),
]);
__resetCache();
assert.deepEqual((await quotas({ home: BOB })).tools, []);
// mia's files now belong to another uid: none is read.
+2 -2
View File
@@ -37,11 +37,11 @@ describe("what a publish copies", () => {
it("a file in an npm package: the package folder (scoped too)", () => {
const plain = write(
path.join(TMP, "npm", "lib", "node_modules", "opencode-ai", "bin", "oc.js"),
path.join(TMP, "npm", "lib", "node_modules", "some-cli", "bin", "oc.js"),
"#!/usr/bin/env node\n"
);
assert.deepEqual(shared.planFor(plain), {
src: path.join(TMP, "npm", "lib", "node_modules", "opencode-ai"),
src: path.join(TMP, "npm", "lib", "node_modules", "some-cli"),
entry: path.join("bin", "oc.js"),
});
const scoped = write(
+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 }]);
});
});
+1 -1
View File
@@ -13,7 +13,7 @@ id follows, as one more argv element — `pty-run.js` emits them in place of
`resume` when a start carries `resumeSessionId`, and only for an id that starts
with a letter or digit. A tool without it (and without a `launcher`) refuses a
resume by id (`EUNSUPPORTED`) rather than start fresh under a "resume".
Built-in: omp, Grok and Cursor (`--resume`), OpenCode (`--session`). omp always gets `--allow-home`: started in its account's home (a workspace there) it would otherwise move to a temp dir, and its session would name that folder, not the workspace's. A session id may carry `_` (OpenCode's `ses_…`).
Built-in: omp and Cursor (`--resume`). omp always gets `--allow-home`: started in its account's home (a workspace there) it would otherwise move to a temp dir, and its session would name that folder, not the workspace's. A session id may carry `_`.
## Install / update (A2)
-41
View File
@@ -1,41 +0,0 @@
{
"id": "grok",
"label": "Grok Build",
"detect": [
"grok"
],
"prompt": {
"mode": "argv",
"separator": "--"
},
"model": {
"flag": "--model"
},
"resume": [
"--continue"
],
"resumeById": [
"--resume"
],
"bypass": [
"--permission-mode",
"bypassPermissions"
],
"homepageUrl": "https://x.ai/cli",
"faviconDomain": "x.ai",
"install": {
"kind": "script",
"url": "https://x.ai/cli/install.sh"
},
"update": {
"kind": "self",
"args": [
"update"
]
},
"config": [
{
"path": "~/.grok/config.toml"
}
]
}
+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"
-37
View File
@@ -1,37 +0,0 @@
{
"id": "opencode",
"label": "OpenCode",
"detect": [
"opencode"
],
"prompt": {
"mode": "flag",
"flag": "--prompt"
},
"model": {
"flag": "--model"
},
"resume": [
"--continue"
],
"resumeById": [
"--session"
],
"homepageUrl": "https://opencode.ai/docs/cli/",
"install": {
"kind": "npm",
"package": "opencode-ai"
},
"update": {
"kind": "npm"
},
"config": [
{
"path": "~/.config/opencode/opencode.json",
"format": "jsonc"
},
{
"path": "~/.config/opencode/opencode.jsonc"
}
]
}
+6 -17
View File
@@ -964,9 +964,15 @@ try {
// (`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`).
// `version`: a skill snapshot's content hash; `source_hash`: its source
// folder's hash when last taken — a skill follows its owner's folder and an
// installer whose copy differs from `version` is offered Update
// (`syncSkillSources`/`updateCheck`). NULL until first computed.
for (const [col, ddl] of [
["auto", "auto INTEGER NOT NULL DEFAULT 0"],
["suggested_owner_id", "suggested_owner_id INTEGER"],
["version", "version TEXT"],
["source_hash", "source_hash TEXT"],
]) {
try {
db.prepare(`SELECT ${col} FROM artifacts LIMIT 1`).get();
@@ -1486,23 +1492,6 @@ db.exec(`
CREATE INDEX IF NOT EXISTS idx_omp_sessions_session ON omp_sessions(session_id);
`);
// Which OpenCode stores have been read at what mtime, and per root session the
// newest `time_updated` written (server/lib/opencode-usage.js) — the same
// "deleted stays deleted" rule.
db.exec(`
CREATE TABLE IF NOT EXISTS opencode_stores (
path TEXT PRIMARY KEY,
mtime_ms INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE TABLE IF NOT EXISTS opencode_sessions (
session_id TEXT PRIMARY KEY,
store TEXT NOT NULL,
time_updated INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
`);
// Which Cursor agent transcripts have been read, at what mtime
// (server/lib/cursor-usage.js) — the same "deleted stays deleted" rule.
db.exec(`
+30 -11
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.
@@ -768,17 +793,11 @@ function startBackgroundServices() {
} catch (err) {
console.warn("cursor usage poll failed to start:", err.message);
}
// Oh My Pi and OpenCode sessions, subagents and token usage, from each CLI's
// own session store (omp-usage.js, opencode-usage.js).
for (const [name, mod, start] of [
["omp", "./lib/omp-usage", "startOmpUsagePoll"],
["opencode", "./lib/opencode-usage", "startOpencodeUsagePoll"],
]) {
try {
require(mod)[start](broadcast);
} catch (err) {
console.warn(`${name} usage poll failed to start:`, err.message);
}
// Oh My Pi sessions, subagents and token usage, from its own session journals.
try {
require("./lib/omp-usage").startOmpUsagePoll(broadcast);
} catch (err) {
console.warn("omp usage poll failed to start:", err.message);
}
// Codex/Antigravity hooks the user installed forward through a script whose
// content names this build; point it here (the command in the tool's
+207
View File
@@ -0,0 +1,207 @@
/**
* @file agent-edits.js
* @description The files a Claude subagent wrote or edited, read from its own
* transcript (`agent-<id>.jsonl`), so the parent's chat can count them in its
* turn's "Edited N files" card (issue #86: a skill that hands its work to
* subagents showed only the parent's own edits). Each file's +/- lines come
* from the same `patchCounts` the chat uses; a failed call counts nothing; a
* subagent's own subagents are folded in, `MAX_DEPTH` levels down. The file is
* read through `readLines` (a member's transcript only as that member's), and
* the answer is cached by path + size + mtime, so a finished subagent is read
* once however often its parent's chat is polled. `turnCarry` does the same
* for the part of a turn the chat's page of messages cut off.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const { patchCounts, agentInfo } = require("./transcript-message");
const {
getSubagentTranscriptPath,
findSubagentTranscriptPath,
getSnapshotSubagentTranscriptPath,
} = require("./claude-home");
const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
const AGENT_TOOLS = new Set(["Agent", "Task"]);
const MAX_DEPTH = 3;
const CACHE_MAX = 500;
// path → { key: "<size>:<mtime>", edits, agents }
const cache = new Map();
/** One transcript's own edits and the subagents it started. */
async function scan(file) {
const { readLines } = require("./run-as");
const pending = new Map(); // tool_use id → file path
const edits = new Map(); // path → { added, removed, counted }
const agents = [];
for await (const line of readLines(file)) {
if (!line.trim()) continue;
let entry;
try {
entry = JSON.parse(line);
} catch {
continue;
}
const content = entry.message?.content;
if (!Array.isArray(content)) continue;
if (entry.type === "assistant") {
for (const b of content) {
if (b?.type !== "tool_use" || !EDIT_TOOLS.has(b.name) || !b.id) continue;
const p = b.input?.file_path ?? b.input?.notebook_path;
if (typeof p === "string" && p) pending.set(b.id, p);
}
} else if (entry.type === "user") {
for (const b of content) {
if (b?.type !== "tool_result") continue;
const agent = agentInfo(entry.toolUseResult);
if (agent) agents.push(agent.id);
const p = pending.get(b.tool_use_id);
if (!p || b.is_error) continue;
const diff = patchCounts(entry.toolUseResult);
const f = edits.get(p) ?? { added: 0, removed: 0, counted: false };
f.added += diff?.added ?? 0;
f.removed += diff?.removed ?? 0;
f.counted ||= !!diff;
edits.set(p, f);
}
}
}
return { edits, agents };
}
async function scanCached(file) {
const st = await fs.promises.stat(file);
const key = `${st.size}:${st.mtimeMs}`;
const hit = cache.get(file);
if (hit && hit.key === key) return hit;
const fresh = { key, ...(await scan(file)) };
cache.delete(file);
cache.set(file, fresh);
if (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value);
return fresh;
}
const transcriptOf = (sessionId, cwd, agentId) =>
getSubagentTranscriptPath(sessionId, cwd, agentId) ||
findSubagentTranscriptPath(sessionId, agentId, null, cwd) ||
getSnapshotSubagentTranscriptPath(sessionId, agentId);
/**
* The files subagent `agentId` of session `sessionId` (and its own subagents)
* changed, as `[{ path, added, removed, counted }]` in first-edit order; an
* empty list when its transcript cannot be found or read.
*/
async function agentEdits(sessionId, cwd, agentId, depth = 0, seen = new Set()) {
if (seen.has(agentId)) return [];
seen.add(agentId);
let file;
let got;
try {
file = transcriptOf(sessionId, cwd, agentId);
if (!file) return [];
got = await scanCached(file);
} catch {
return [];
}
const out = new Map();
const add = (p, f) => {
const o = out.get(p) ?? { added: 0, removed: 0, counted: false };
o.added += f.added;
o.removed += f.removed;
o.counted ||= f.counted;
out.set(p, o);
};
got.edits.forEach((f, p) => add(p, f));
if (depth < MAX_DEPTH) {
for (const id of got.agents) {
for (const f of await agentEdits(sessionId, cwd, id, depth + 1, seen)) add(f.path, f);
}
}
return [...out].map(([path, f]) => ({ path, ...f }));
}
/** Puts `edits` on every Agent/Task result in `messages` (in place). */
async function attachAgentEdits(messages, sessionId, cwd) {
for (const m of messages) {
for (const c of m.content ?? []) {
if (c.type !== "tool_result" || !c.agent?.id || c.is_error) continue;
const edits = await agentEdits(sessionId, cwd, c.agent.id);
if (edits.length) c.edits = edits;
}
}
}
/** A human prompt: the line that starts a turn (the chat's own rule). */
const isPrompt = (m) =>
(m.sender ?? (m.type === "assistant" ? "assistant" : "user")) === "user" &&
(m.content ?? []).some((c) => c.type === "text");
/**
* The edits of the turn a page of the chat starts in the middle of. The route
* keeps only the newest `limit` messages; every message it drops goes through
* `drop()`, which keeps the file changes since the last prompt it dropped.
* `result(window)` then answers the files that turn changed BEFORE the page's
* first message — `[{ path, added, removed, counted }]`, or null when the page
* starts on a prompt or nothing was changed — so the turn's "Edited N files"
* card is whole although its start was never sent (issue #86: a 120-message
* turn showed 1 of its files).
*/
function turnCarry() {
let pending = new Map(); // dropped Write/Edit call id → path
let pendingAgents = new Set(); // dropped Agent/Task call ids
let files = new Map();
let agents = [];
const add = (p, f) => {
const o = files.get(p) ?? { added: 0, removed: 0, counted: false };
o.added += f?.added ?? 0;
o.removed += f?.removed ?? 0;
o.counted ||= !!f && f.counted !== false;
files.set(p, o);
};
return {
drop(m) {
if (!m) return;
if (isPrompt(m)) {
pending = new Map();
pendingAgents = new Set();
files = new Map();
agents = [];
return;
}
for (const c of m.content ?? []) {
if (c.type === "tool_use" && c.id) {
if (AGENT_TOOLS.has(c.name)) pendingAgents.add(c.id);
if (!EDIT_TOOLS.has(c.name)) continue;
const p = c.input?.file_path ?? c.input?.notebook_path;
if (typeof p === "string" && p) pending.set(c.id, p);
} else if (c.type === "tool_result") {
if (c.agent?.id && !c.is_error) agents.push(c.agent.id);
pendingAgents.delete(c.id);
const p = pending.get(c.id);
if (!p) continue;
pending.delete(c.id);
if (!c.is_error) add(p, c.diff ?? null);
}
}
},
async result(window, sessionId, cwd) {
if (!window.length || isPrompt(window[0])) return null;
// A call dropped whose result is on the page: the page shows the result
// with no call to hang it on, so it counts here.
for (const m of window) {
for (const c of m.content ?? []) {
if (c.type !== "tool_result" || c.is_error) continue;
const p = pending.get(c.id);
if (p) add(p, c.diff ?? null);
if (pendingAgents.has(c.id)) for (const e of c.edits ?? []) add(e.path, e);
}
}
for (const id of agents) {
for (const e of await agentEdits(sessionId, cwd, id)) add(e.path, e);
}
return files.size ? [...files].map(([path, f]) => ({ path, ...f })) : null;
},
};
}
module.exports = { agentEdits, attachAgentEdits, turnCarry, _cache: cache };
+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;
+287 -17
View File
@@ -226,7 +226,11 @@ function list(req, { scope = "all", kind, q, laneId, sessionId, limit = 200 } =
).all(...params);
const owners = new Map();
const installed = installedCheck(req);
const out = rows.map((r) => ({ ...view(r, req, owners), installed: installed(r) }));
const updatable = updateCheck(req);
const out = rows.map((r) => {
const isIn = installed(r);
return { ...view(r, req, owners), installed: isIn, updateAvailable: isIn && updatable(r) };
});
return scope === "mine" && req?.user ? out.filter((a) => a.mine || a.installed) : out;
}
@@ -753,7 +757,7 @@ function walkSkill(dir) {
total += st.size;
if (files.length >= SKILL_MAX_FILES) throw fail("ETOOBIG", "the skill has too many files");
if (total > SKILL_MAX_BYTES) throw fail("ETOOBIG", "the skill is larger than 5 MB");
files.push({ abs: childAbs, path: childRel, size: st.size });
files.push({ abs: childAbs, path: childRel, size: st.size, mtimeMs: st.mtimeMs });
}
};
walk(dir, "", 0);
@@ -822,6 +826,7 @@ function storeSkill(req, { name, files, title, description, laneId = null }) {
const snap = newSnapshotDir();
let total = 0;
const recorded = [];
const hashed = [];
try {
for (const f of files) {
const data = typeof f.data === "function" ? f.data() : f.data;
@@ -831,6 +836,7 @@ function storeSkill(req, { name, files, title, description, laneId = null }) {
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
fs.writeFileSync(target, data, { mode: 0o600 });
recorded.push({ path: f.path, size: data.length });
hashed.push({ path: f.path, data });
}
} catch (err) {
dropSnapshot(snap.id);
@@ -845,20 +851,30 @@ function storeSkill(req, { name, files, title, description, laneId = null }) {
} catch {
/* no frontmatter */
}
return view(
insert({
kind: "skill",
title: cleanTitle(title, name),
description: cleanDescription(description) || summary.slice(0, DESCRIPTION_MAX),
owner_id: req?.user ? req.user.id : null,
lane_id: laneId,
source: name,
snapshot: snap.id,
files: JSON.stringify(recorded),
size: total,
}),
req
);
const row = insert({
kind: "skill",
title: cleanTitle(title, name),
description: cleanDescription(description) || summary.slice(0, DESCRIPTION_MAX),
owner_id: req?.user ? req.user.id : null,
lane_id: laneId,
source: name,
snapshot: snap.id,
files: JSON.stringify(recorded),
size: total,
version: contentHash(hashed),
});
// The owner's folder as it is now: only a LATER change to it is taken
// (`syncOne`), so an upload is never replaced by an older local copy.
try {
const h = sourceHash(row);
if (h) {
sql("UPDATE artifacts SET source_hash = ? WHERE id = ?").run(h, row.id);
row.source_hash = h;
}
} catch {
/* no folder */
}
return view(row, req);
}
// ── skill: written or uploaded ────────────────────────────────────────────
@@ -1569,10 +1585,22 @@ function editSkill(req, row, { name, description, content }) {
throw err;
}
const desc = frontmatterField(text, "description");
// The source folder as it is now, so this edit is not undone by it (`syncOne`).
const folder = sourceHash({ ...row, source: n });
sql(
`UPDATE artifacts SET source = ?, snapshot = ?, files = ?, size = ?, description = ?,
version = ?, source_hash = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
).run(n, snap.id, JSON.stringify(next), total, cleanDescription(desc), row.id);
).run(
n,
snap.id,
JSON.stringify(next),
total,
cleanDescription(desc),
contentHash(all),
folder,
row.id
);
dropSnapshot(row.snapshot);
}
@@ -1756,6 +1784,237 @@ async function installSkill(req, row, { replace = false } = {}) {
return { installed: paths.join(", "), paths, replaced: taken.length > 0 };
}
// ── skill versions: follow the owner's folder, offer installers an Update ──
/** A skill's content id: sha256 over its files (path + bytes), path order. */
function contentHash(files) {
const h = crypto.createHash("sha256");
for (const f of [...files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))) {
h.update(f.path);
h.update("\0");
h.update(crypto.createHash("sha256").update(f.data).digest());
}
return h.digest("hex").slice(0, 16);
}
/** A skill row's snapshot version (`artifacts.version`), computed once for an older row. */
function snapshotVersion(row) {
if (row.version) return row.version;
if (row.kind !== "skill" || !row.snapshot || !SNAPSHOT_RE.test(row.snapshot)) return null;
try {
const files = (row.files ? JSON.parse(row.files) : []).map((f) => ({
path: f.path,
data: fs.readFileSync(path.join(root(), row.snapshot, ...f.path.split("/"))),
}));
const v = contentHash(files);
sql("UPDATE artifacts SET version = ? WHERE id = ?").run(v, row.id);
row.version = v;
return v;
} catch {
return null;
}
}
// realpath → { sig, hash }: a folder is read again only once a file's name,
// size or mtime changed (a stat walk is cheap; reading 5 MB per poll is not).
const folderCache = new Map();
const FOLDER_CACHE_MAX = 2000;
/**
* A skill folder as it is on disk now — `{ real, hash, files, read() }` — or
* null when it is missing, has no SKILL.md, is a link out of `top`, or breaks
* a snapshot rule (`walkSkill`: no symlink, no secret, ≤ 5 MB). `read()`
* answers its files' bytes, a member's only as theirs (`readFileSafe`).
*/
function folderState(dir, top) {
let real;
let topReal;
try {
real = fs.realpathSync(dir);
topReal = fs.realpathSync(top);
} catch {
return null;
}
if (!inside(topReal, real) || path.basename(real) !== path.basename(dir)) return null;
let walked;
try {
walked = walkSkill(real);
} catch {
return null;
}
if (!walked.files.some((f) => f.path === "SKILL.md")) return null;
const sig = walked.files
.map((f) => `${f.path}:${f.size}:${f.mtimeMs}`)
.sort()
.join("|");
const read = () => {
const runAs = require("./run-as");
return walked.files.map((f) => ({
path: f.path,
data: runAs.readFileSafe(f.abs, null, { max: SKILL_MAX_BYTES }),
}));
};
const hit = folderCache.get(real);
if (hit && hit.sig === sig) return { real, hash: hit.hash, files: walked.files, read };
let hash;
try {
hash = contentHash(read());
} catch {
return null;
}
folderCache.delete(real);
folderCache.set(real, { sig, hash });
if (folderCache.size > FOLDER_CACHE_MAX) folderCache.delete(folderCache.keys().next().value);
return { real, hash, files: walked.files, read };
}
/**
* Where a skill row came from: `{dir, top}` — its workspace's
* `.claude/skills/<name>` when it was taken from one, else its OWNER's
* `~/.claude/skills/<name>` (the host's in local mode). Null for a row with no
* owner in server mode, or an owner with no Claude config of their own.
*/
function sourceDirOf(row) {
if (row.kind !== "skill" || !row.source || !SKILL_NAME_RE.test(row.source)) return null;
if (row.lane_id != null) {
const lane = sql("SELECT cwd FROM lanes WHERE id = ?").get(row.lane_id);
if (!lane?.cwd) return null;
return { dir: path.join(lane.cwd, ".claude", "skills", row.source), top: lane.cwd };
}
let claudeHome;
if (row.owner_id == null) {
if (isServer()) return null;
claudeHome = getClaudeHome();
} else {
const user = sql("SELECT id, username, role FROM users WHERE id = ?").get(row.owner_id);
if (!user) return null;
try {
claudeHome = ownAccount({ user }).claudeHome;
} catch {
return null;
}
}
return { dir: path.join(claudeHome, "skills", row.source), top: claudeHome };
}
/** The hash of a row's source folder now, or null when it has none. */
function sourceHash(row) {
const src = sourceDirOf(row);
return src ? (folderState(src.dir, src.top)?.hash ?? null) : null;
}
/**
* Take a skill row's NEW source folder as its snapshot, when the folder
* changed since it was last taken (`source_hash`) and differs from the
* snapshot. A change made in the Hub (`editSkill`) records the folder as it
* was then, so it is not undone until the folder itself changes again: the
* newer change wins. A new snapshot directory, then the old one dropped —
* never a write into the one being read. Answers whether the row changed.
*/
function syncOne(row) {
const src = sourceDirOf(row);
if (!src) return false;
const st = folderState(src.dir, src.top);
if (!st || st.hash === row.source_hash) return false;
if (st.hash === snapshotVersion(row)) {
sql("UPDATE artifacts SET source_hash = ? WHERE id = ?").run(st.hash, row.id);
return false;
}
let files;
try {
files = st.read();
checkSkillFiles(files);
} catch {
return false;
}
const snap = newSnapshotDir();
let total = 0;
const next = [];
try {
for (const f of files) {
const target = path.join(snap.dir, ...f.path.split("/"));
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
fs.writeFileSync(target, f.data, { mode: 0o600 });
total += f.data.length;
next.push({ path: f.path, size: f.data.length });
}
} catch {
dropSnapshot(snap.id);
return false;
}
// The description follows SKILL.md's only while it still says what the old one did.
const md = (list) => list.find((f) => f.path === "SKILL.md");
let oldSummary = "";
try {
oldSummary = frontmatterField(
fs.readFileSync(path.join(root(), row.snapshot, "SKILL.md"), "utf8"),
"description"
).slice(0, DESCRIPTION_MAX);
} catch {
/* none */
}
const newSummary = frontmatterField(md(files)?.data.toString("utf8") ?? "", "description");
const description =
!row.description || row.description === oldSummary
? cleanDescription(newSummary) || row.description
: row.description;
sql(
`UPDATE artifacts SET snapshot = ?, files = ?, size = ?, version = ?, source_hash = ?,
description = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
).run(snap.id, JSON.stringify(next), total, st.hash, st.hash, description, row.id);
if (row.snapshot && SNAPSHOT_RE.test(row.snapshot)) dropSnapshot(row.snapshot);
return true;
}
const SYNC_EVERY_MS = 15 * 1000;
let lastSync = 0;
/**
* Bring every skill row up to its source folder (`syncOne`), at most once per
* `SYNC_EVERY_MS` unless `force`. Runs when the Hub is listed. Answers the
* rows that changed, for the route to announce.
*/
function syncSkillSources({ force = false } = {}) {
if (!force && Date.now() - lastSync < SYNC_EVERY_MS) return [];
lastSync = Date.now();
const changedRows = [];
for (const row of sql(
"SELECT * FROM artifacts WHERE kind = 'skill' AND snapshot IS NOT NULL"
).all()) {
try {
if (syncOne(row)) changedRows.push(sql("SELECT * FROM artifacts WHERE id = ?").get(row.id));
} catch {
/* one bad row never stops the rest */
}
}
return changedRows;
}
/**
* `row => boolean`: an installed skill whose copy in the viewer's
* `~/.claude/skills/<name>` (any account it runs as) differs from the row's
* newest snapshot — the list offers **Update** for it. Pure reads.
*/
function updateCheck(req) {
let accounts = null;
return (row) => {
if (row.kind !== "skill" || !row.source) return false;
const version = snapshotVersion(row);
if (!version) return false;
if (accounts === null) {
try {
accounts = installAccounts(req);
} catch {
accounts = [];
}
}
return accounts.some((a) => {
const st = folderState(path.join(a.claudeHome, "skills", row.source), a.claudeHome);
return !!st && st.hash !== version;
});
};
}
let execImpl = promisify(require("node:child_process").execFile);
function __setExecImpl(fn) {
execImpl = fn;
@@ -1815,6 +2074,16 @@ async function installPlugin(req, row) {
async function install(req, id, opts = {}) {
const row = load(req, id);
installable(req, row);
// Update: an installed skill's copies replaced by the newest snapshot (the
// old ones moved aside like any replace). Not another install: no count, no
// second row in who-installed.
if (opts.update) {
if (row.kind !== "skill") throw fail("EBADKIND", "only a skill is updated here");
if (!installedCheck(req)(row)) throw fail("ENOTINSTALLED", `${row.source} is not installed`);
const result = await installSkill(req, row, { replace: true });
if (req?.user && row.owner_id !== req.user.id) audit(req, row, "updated");
return result;
}
const result =
row.kind === "skill" ? await installSkill(req, row, opts) : await installPlugin(req, row);
sql("UPDATE artifacts SET installs = installs + 1 WHERE id = ?").run(row.id);
@@ -1947,6 +2216,7 @@ module.exports = {
update,
remove,
snapshotFile,
syncSkillSources,
install,
uninstall,
installsEnabled,
+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 };
-504
View File
@@ -1,504 +0,0 @@
/**
* @file OpenCode (`opencode`) sessions, subagents and token usage, read from
* OpenCode's own SQLite store (`~/.local/share/opencode/opencode.db`, under
* `XDG_DATA_HOME` when set). This is how an OpenCode session reaches
* `sessions` (as `tool = 'opencode'`), its tokens `token_usage` and its
* subagents `agents` — OpenCode has no hooks AgentHub installs.
*
* What the store holds (opencode 1.19, verified against a file it wrote — not
* a documented format):
* - `session(id, parent_id, directory, title, time_created, time_updated,
* time_archived)` — a session the `task` tool spawned has `parent_id` set,
* and is shown as a subagent of its root session;
* - `message(session_id, data)` — an assistant message's JSON carries
* `modelID` and `tokens: {input, output, reasoning, cache: {read, write}}`
* (input NOT including cache reads; reasoning is counted as output here);
* - `part(message_id, session_id, data)` — the message's text, reasoning and
* tool parts, read only for a transcript.
* The file is re-read when its mtime (or its non-empty `-wal`'s) changes; per
* root session the newest `time_updated` seen lives in `opencode_sessions`,
* so a session the user deleted stays deleted until OpenCode writes to it
* again. Status: active while its tree was updated in the last 2 minutes or a
* live dashboard pane holds it, else completed. S3: a member's store (and its
* WAL) is read through the guard into a private copy, never opened in place.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { db, stmts, Database } = require("../db");
const { claimToolSessions } = require("./dashboard-runs");
const pricingSync = require("./pricing-sync");
const ACTIVE_MS = 120_000; // the window codex-usage.js calls a session active
const FUTURE_SLACK_MS = 1000;
const SESSION_ID = /^[A-Za-z0-9_-]{8,120}$/;
const MAX_COPY = 256 << 20;
/** OpenCode's data dir: `AGENTHUB_OPENCODE_DIR`, `$XDG_DATA_HOME/opencode`, else `~/.local/share/opencode`. */
function getOpencodeDir() {
if (process.env.AGENTHUB_OPENCODE_DIR) return process.env.AGENTHUB_OPENCODE_DIR;
const xdg = process.env.XDG_DATA_HOME;
return path.join(xdg && path.isAbsolute(xdg) ? xdg : path.join(os.homedir(), ".local", "share"), "opencode");
}
/** Every store: `{path, mtime}` — this account's, then every member's. */
function storeFiles() {
const members = require("./run-as")
.memberHomes()
.map((h) => path.join(h, ".local", "share", "opencode"));
const out = [];
for (const dir of [getOpencodeDir(), ...members]) {
const file = path.join(dir, "opencode.db");
try {
const st = fs.lstatSync(file);
if (!st.isFile()) continue;
let mtime = st.mtimeMs;
try {
const wal = fs.lstatSync(`${file}-wal`);
if (wal.isFile() && wal.size > 0) mtime = Math.max(mtime, wal.mtimeMs);
} catch {
/* no WAL right now */
}
out.push({ path: file, mtime: Math.floor(mtime) });
} catch {
/* no store */
}
}
return out;
}
/**
* Run `fn(conn)` on a store, read-only. A member's store (S3) and its WAL are
* copied through the guard first: SQLite opens by NAME, and a member's FIFO
* standing in for the `-wal` would block this thread.
*/
function withStore(file, fn) {
const { ownerForRead, readFileSafe } = require("./run-as");
let tmp = null;
try {
let open = file;
if (require("./linux-accounts").s3Enabled() && ownerForRead(file)) {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-opencode-"));
open = path.join(tmp, "opencode.db");
fs.writeFileSync(open, readFileSafe(file, null, { max: MAX_COPY }));
try {
fs.writeFileSync(`${open}-wal`, readFileSafe(`${file}-wal`, null, { max: MAX_COPY }));
} catch {
/* no WAL (or not theirs): what is in the main file */
}
}
const conn = new Database(open, { readonly: !tmp, fileMustExist: true });
try {
return fn(conn);
} finally {
conn.close();
}
} finally {
if (tmp) fs.rmSync(tmp, { recursive: true, force: true });
}
}
/** A session's model column (`{"id": …}` JSON, or a plain id), or null. */
function modelOf(raw) {
if (typeof raw !== "string" || !raw) return null;
try {
const o = JSON.parse(raw);
return typeof o?.id === "string" ? o.id.slice(0, 128) : null;
} catch {
return raw.slice(0, 128);
}
}
/**
* Everything a pass needs from one store: `[{root, children, usage, updated}]`
* per root session — `usage` per model over the root and every descendant.
*/
function readStore(file) {
return withStore(file, (conn) => {
const rows = conn
.prepare(
`SELECT id, parent_id, directory, title, time_created, time_updated, time_archived, model
FROM session`
)
.all();
const byId = new Map(rows.map((r) => [r.id, r]));
const rootOf = (r) => {
let cur = r;
for (let i = 0; i < 32 && cur.parent_id && byId.has(cur.parent_id); i++) {
cur = byId.get(cur.parent_id);
}
return cur;
};
const trees = new Map();
for (const r of rows) {
if (!SESSION_ID.test(r.id)) continue;
const root = rootOf(r);
if (!SESSION_ID.test(root.id)) continue;
const t = trees.get(root.id) || { root, children: [], usage: {}, updated: 0, model: null };
if (r !== root) t.children.push(r);
t.updated = Math.max(t.updated, Number(r.time_updated) || 0);
trees.set(root.id, t);
}
const sums = conn
.prepare(
`SELECT session_id,
json_extract(data, '$.modelID') AS model,
SUM(COALESCE(json_extract(data, '$.tokens.input'), 0)) AS input,
SUM(COALESCE(json_extract(data, '$.tokens.output'), 0)
+ COALESCE(json_extract(data, '$.tokens.reasoning'), 0)) AS output,
SUM(COALESCE(json_extract(data, '$.tokens.cache.read'), 0)) AS cache_read,
SUM(COALESCE(json_extract(data, '$.tokens.cache.write'), 0)) AS cache_write,
MAX(time_created) AS last
FROM message
WHERE json_extract(data, '$.role') = 'assistant'
GROUP BY session_id, model`
)
.all();
for (const s of sums) {
const r = byId.get(s.session_id);
const t = r && trees.get(rootOf(r).id);
if (!t) continue;
const model = typeof s.model === "string" && s.model ? s.model.slice(0, 128) : "unknown";
const u = (t.usage[model] ||= { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
u.input += Number(s.input) || 0;
u.output += Number(s.output) || 0;
u.cacheRead += Number(s.cache_read) || 0;
u.cacheWrite += Number(s.cache_write) || 0;
if (r === t.root && (!t.lastAt || s.last > t.lastAt)) {
t.lastAt = s.last;
t.model = model;
}
}
return [...trees.values()];
});
}
const getStore = db.prepare("SELECT * FROM opencode_stores WHERE path = ?");
const upsertStore = db.prepare(
`INSERT INTO opencode_stores (path, mtime_ms, updated_at)
VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
ON CONFLICT(path) DO UPDATE SET mtime_ms = excluded.mtime_ms, updated_at = excluded.updated_at`
);
const getTracked = db.prepare("SELECT * FROM opencode_sessions WHERE session_id = ?");
const upsertTracked = db.prepare(
`INSERT INTO opencode_sessions (session_id, store, time_updated, updated_at)
VALUES (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
ON CONFLICT(session_id) DO UPDATE SET store = excluded.store,
time_updated = excluded.time_updated, updated_at = excluded.updated_at`
);
const activeOpencode = db.prepare(
"SELECT s.id, t.time_updated FROM sessions s JOIN opencode_sessions t ON t.session_id = s.id WHERE s.tool = 'opencode' AND s.status = 'active'"
);
const heldByPane = db.prepare(
"SELECT 1 FROM dashboard_runs WHERE session_id = ? AND status IN ('running', 'spawning') LIMIT 1"
);
const insertSession = db.prepare(
`INSERT INTO sessions (id, name, status, cwd, model, started_at, ended_at, updated_at, metadata, tool)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'opencode')`
);
const updateSession = db.prepare(
`UPDATE sessions SET name = ?, status = ?, model = COALESCE(?, model), ended_at = ?,
updated_at = ?, cwd = COALESCE(cwd, ?), started_at = COALESCE(?, started_at)
WHERE id = ? AND tool = 'opencode'`
);
const settle = db.prepare(
"UPDATE sessions SET status = 'completed', ended_at = ? WHERE id = ? AND tool = 'opencode'"
);
const clearUsage = db.prepare("DELETE FROM token_usage WHERE session_id = ?");
const agentTimes = db.prepare(
"UPDATE agents SET started_at = COALESCE(?, started_at), ended_at = ? WHERE id = ?"
);
const fresh = (t, now) => t - now < FUTURE_SLACK_MS && now - t < ACTIVE_MS;
const iso = (t) => (Number.isFinite(Number(t)) && Number(t) > 0 ? new Date(Number(t)).toISOString() : null);
/** Write one root session's row, usage and subagents. @returns its broadcast type, or null. */
function writeSession(store, t, now, broadcast) {
const r = t.root;
const existing = stmts.getSession.get(r.id);
if (existing && existing.tool !== "opencode") return null;
const cwd = typeof r.directory === "string" && path.isAbsolute(r.directory) ? r.directory : null;
const runAs = require("./run-as");
// S3: a member's store names only their own folder and session.
if (!runAs.sameOwner(store, cwd, existing?.cwd)) return null;
const lastWrite = iso(t.updated) || new Date(now).toISOString();
const active = !r.time_archived && (fresh(t.updated, now) || !!heldByPane.get(r.id));
const status = active ? "active" : "completed";
const endedAt = active ? null : lastWrite;
const name =
(typeof r.title === "string" && r.title.trim().slice(0, 200)) ||
existing?.name ||
`OpenCode ${r.id.slice(4, 12)}`;
const model = t.model || modelOf(r.model);
const agentEvents = [];
db.transaction(() => {
if (existing) {
updateSession.run(name, status, model, endedAt, lastWrite, cwd, iso(r.time_created), r.id);
} else {
insertSession.run(
r.id,
name,
status,
cwd,
model,
iso(r.time_created) || lastWrite,
endedAt,
lastWrite,
JSON.stringify({ source: "opencode-store" })
);
}
claimToolSessions(r.id);
clearUsage.run(r.id);
for (const [m, u] of Object.entries(t.usage)) {
stmts.setTokenUsage.run(
r.id,
m,
"standard",
"global",
"standard",
u.input,
u.output,
u.cacheRead,
u.cacheWrite,
0,
0,
0,
0
);
}
for (const c of t.children) {
const id = `opencode-${c.id}`;
const done = !active || !fresh(Number(c.time_updated) || 0, now);
const agentStatus = done ? "completed" : "working";
const ended = done ? iso(c.time_updated) : null;
const label = (typeof c.title === "string" && c.title.trim().slice(0, 120)) || c.id;
const prior = stmts.getAgent.get(id);
if (prior && prior.session_id !== r.id) continue;
if (!prior) {
stmts.insertAgent.run(
id,
r.id,
label,
"subagent",
"task",
agentStatus,
label,
null,
JSON.stringify({ source: "opencode-store", transcript: c.id })
);
} else if (prior.status !== agentStatus || prior.name !== label) {
stmts.updateAgent.run(label, agentStatus, label, null, ended, null, id);
} else continue;
agentTimes.run(iso(c.time_created), ended, id);
agentEvents.push([prior ? "agent_updated" : "agent_created", id]);
}
upsertTracked.run(r.id, store, t.updated);
})();
for (const [type, id] of agentEvents) broadcast(type, stmts.getAgent.get(id));
return existing ? "session_updated" : "session_created";
}
let running = null;
let lastPending = 0;
/**
* One pass: every store whose file changed is read, and each root session
* updated since it was last written is rewritten; an `active` one that went
* quiet is settled.
* @returns {Promise<{files: number, sessions: number}>}
*/
function ingestOpencodeUsage({ broadcast = () => {}, now = Date.now() } = {}) {
if (running) return running;
running = (async () => {
let read = 0;
let written = 0;
const done = new Set();
for (const f of storeFiles()) {
const seen = getStore.get(f.path);
if (seen && seen.mtime_ms === f.mtime) continue;
let trees;
try {
trees = readStore(f.path);
} catch (err) {
if (getStore.get(f.path)?.mtime_ms !== f.mtime) {
console.warn(`[opencode-usage] skipped ${f.path}: ${err.message}`);
}
upsertStore.run(f.path, f.mtime);
continue;
}
read += 1;
for (const t of trees) {
const tracked = getTracked.get(t.root.id);
const changed = !tracked || tracked.time_updated !== t.updated;
const s = stmts.getSession.get(t.root.id);
// Unchanged and deleted by the user: stays deleted.
if (!changed && (!s || s.status !== "active")) continue;
try {
const type = writeSession(f.path, t, now, broadcast);
if (!type) continue;
done.add(t.root.id);
written += 1;
broadcast(type, stmts.getSession.get(t.root.id));
} catch (err) {
console.warn(`[opencode-usage] session ${t.root.id}: ${err.message}`);
}
}
upsertStore.run(f.path, f.mtime);
await new Promise((r) => setImmediate(r));
}
// An active session in a store that did not change: settled once quiet.
let pending = 0;
for (const { id, time_updated: updated } of activeOpencode.all()) {
if (done.has(id)) {
pending += 1;
continue;
}
if (fresh(updated, now) || heldByPane.get(id)) {
pending += 1;
continue;
}
settle.run(iso(updated) || new Date(now).toISOString(), id);
for (const a of stmts.listAgentsBySession.all(id)) {
if (a.status === "completed" || a.status === "error") continue;
stmts.updateAgent.run(null, "completed", null, null, iso(updated), null, a.id);
broadcast("agent_updated", stmts.getAgent.get(a.id));
}
written += 1;
broadcast("session_updated", stmts.getSession.get(id));
}
lastPending = pending;
return { files: read, sessions: written };
})().finally(() => {
running = null;
});
return running;
}
/** A tool part's input/output, kept small. */
const short = (v, max = 10240) => {
const s = typeof v === "string" ? v : v === undefined ? "" : JSON.stringify(v);
return s.length > max ? `${s.slice(0, max)}[truncated]` : s;
};
const TEXT_MAX = 1024 * 1024;
/**
* One OpenCode session's conversation (`sessionId`, or one of its subagent
* sessions by `childId`), in the Claude transcript shape the client renders:
* `[{type, sender, timestamp, content}]`, oldest first. Null when unknown.
*/
function transcriptMessages(sessionId, childId = null) {
const tracked = getTracked.get(sessionId);
if (!tracked) return null;
const target = childId || sessionId;
if (!SESSION_ID.test(target)) return null;
return withStore(tracked.store, (conn) => {
if (childId) {
// Only a session of this tree.
let cur = conn.prepare("SELECT id, parent_id FROM session WHERE id = ?").get(childId);
for (let i = 0; i < 32 && cur && cur.id !== sessionId; i++) {
cur = cur.parent_id
? conn.prepare("SELECT id, parent_id FROM session WHERE id = ?").get(cur.parent_id)
: null;
}
if (!cur) return null;
}
const messages = conn
.prepare("SELECT id, time_created, data FROM message WHERE session_id = ? ORDER BY time_created, id")
.all(target);
const parts = conn
.prepare("SELECT message_id, data FROM part WHERE session_id = ? ORDER BY time_created, id")
.all(target);
const byMessage = new Map();
for (const p of parts) {
let d;
try {
d = JSON.parse(p.data);
} catch {
continue;
}
(byMessage.get(p.message_id) || byMessage.set(p.message_id, []).get(p.message_id)).push(d);
}
const out = [];
for (const m of messages) {
let info;
try {
info = JSON.parse(m.data);
} catch {
continue;
}
const at = iso(m.time_created);
const role = info.role === "user" ? "user" : info.role === "assistant" ? "assistant" : null;
if (!role) continue;
const content = [];
const results = [];
for (const p of byMessage.get(m.id) || []) {
if (p.type === "text" && typeof p.text === "string" && p.text.trim() && !p.synthetic) {
content.push({ type: "text", text: short(p.text, TEXT_MAX) });
} else if (p.type === "reasoning" && typeof p.text === "string" && p.text.trim()) {
content.push({ type: "thinking", text: short(p.text) });
} else if (p.type === "tool") {
const st = p.state || {};
content.push({
type: "tool_use",
name: p.tool || "unknown",
id: p.callID || null,
input: st.input && typeof st.input === "object" ? st.input : {},
});
if (st.status === "completed" || st.status === "error") {
results.push({
type: "tool_result",
id: p.callID || null,
output: short(st.status === "error" ? st.error : st.output),
is_error: st.status === "error",
});
}
}
}
if (content.length) {
const sender = role === "user" ? (childId && !out.length ? "orchestrator" : "user") : "assistant";
const msg = { type: role, sender, timestamp: at, content };
if (role === "assistant" && info.modelID) msg.model = info.modelID;
out.push(msg);
}
if (results.length) out.push({ type: "user", sender: "tool", timestamp: at, content: results });
}
return out;
});
}
/** A root session's subagent sessions: `[{id, name, rowId}]`. */
function subagentSessions(sessionId) {
return stmts
.listAgentsBySession.all(sessionId)
.filter((a) => a.id.startsWith("opencode-"))
.map((a) => ({ id: a.id.slice("opencode-".length), name: a.name, rowId: a.id }));
}
/**
* First pass at once, then every DASHBOARD_OPENCODE_POLL_MS (default 15 s; 0
* disables) — once a minute after a pass that found nothing to do.
*/
function startOpencodeUsagePoll(broadcast) {
const POLL_MS = process.env.DASHBOARD_OPENCODE_POLL_MS
? Number(process.env.DASHBOARD_OPENCODE_POLL_MS)
: 15_000;
if (!Number.isFinite(POLL_MS) || POLL_MS <= 0) return;
require("./backoff-poll").startBackoffPoll(
() =>
ingestOpencodeUsage({ broadcast }).then((r) => {
if (r.sessions > 0 && pricingSync.syncEnabled()) void pricingSync.syncPricing(db);
return r.files + r.sessions + lastPending > 0;
}),
{ baseMs: POLL_MS }
);
}
module.exports = {
getOpencodeDir,
ingestOpencodeUsage,
transcriptMessages,
subagentSessions,
startOpencodeUsagePoll,
};
+5 -194
View File
@@ -34,21 +34,6 @@
* names no account and no plan. Cached like Claude; a failed run shows
* the last figure with its `asOf`. Running agy may let it update itself;
* `AGENTHUB_AGY_USAGE=0` never runs it.
* - **Grok Build**: the CLI's `auth.x.ai` session in `$GROK_HOME/auth.json`
* (default `~/.grok`), sent only to `cli-chat-proxy.grok.com`'s
* `GET /v1/billing?format=credits`: `wk` from `creditUsagePercent`, `mo`
* from `used / monthlyLimit`, plan `subscriptionTier`.
* - **Kimi Code**: `$KIMI_CODE_HOME/credentials/kimi-code.json` (default
* `~/.kimi-code`), sent only to `api.kimi.com`'s `GET /coding/v1/usages`:
* `5h`, `wk`, `mo` from `limit_5h`/`limit_7d`/`limit_month_total`.
* - **OpenCode Go**: opencode's own `opencode-go` API key — its `auth.json`
* (OpenCode 1) else its `opencode.db` `credential` table (OpenCode 2),
* under `$XDG_DATA_HOME/opencode` (default `~/.local/share`) — sent only to
* `opencode.ai`'s `GET /zen/go/v1/usage`: `5h`, `wk`, `mo` meters. No Go
* key (another provider's login) means no row.
* Grok's and Kimi's tokens are used only while valid and never refreshed
* (Kimi rotates its refresh token: a refresh here would sign the CLI out).
* These three are Orca's readers (github.com/stablyai/orca, MIT), trimmed.
*
* Each tool also carries its signed-in `account` (an email), its `plan` (the
* subscription's name as the tool reports it: Claude's `subscriptionType`,
@@ -59,9 +44,8 @@
*
* Server mode with S3 (`quotas({home})`): a member's plan is read from THEIR
* Linux account's home only — `.claude`, `.codex`, `.config/cursor` +
* `.cursor`, `.grok`, `.kimi-code`, `.local/share/opencode`, never the
* host's env overrides — through `run-as`'s owner-checked reads (F3). No
* saved Claude profiles (the Profiles modal is the host's) and no agy: its
* `.cursor`, never the host's env overrides — through `run-as`'s
* owner-checked reads (F3). No saved Claude profiles (the Profiles modal is the host's) and no agy: its
* `/usage` needs the self-updating binary, so it stays host-only.
*
* Only percentages, window lengths, reset/expiry times, the plan's name and
@@ -80,9 +64,7 @@ const { readActiveAuth, readProfile, listProfiles } = require("./cc-profiles");
const { fetchUsage, fetchOwner } = require("./cc-usage");
const { allRolloutFiles } = require("./codex-ingest");
const { getCursorDir } = require("./cursor-usage");
const { Database } = require("../db");
const { readFileSafe, openSafe, ownerForRead } = require("./run-as");
const { s3Enabled } = require("./linux-accounts");
const { readFileSafe, openSafe } = require("./run-as");
const CLAUDE_TTL_MS = 5 * 60_000;
const FORCE_MIN_MS = 30_000; // a Refresh click still cannot hammer the endpoint
@@ -91,9 +73,6 @@ const TAIL_BYTES = 512 * 1024;
const CODEX_FILES = 5;
const CURSOR_USAGE_URL = "https://cursor.com/api/usage-summary";
const TIMEOUT_MS = 10_000;
const GROK_BILLING_URL = "https://cli-chat-proxy.grok.com/v1/billing?format=credits";
const KIMI_USAGE_URL = "https://api.kimi.com/coding/v1/usages";
const OPENCODE_GO_URL = "https://opencode.ai/zen/go/v1/usage";
const AGY_TIMEOUT_MS = 60_000;
const AGY_MIN_VERSION = [1, 1, 11];
const AGY_WINDOW = { "5h": "5h", weekly: "wk" };
@@ -364,9 +343,6 @@ function toolDirs(home) {
codex: home ? path.join(home, ".codex") : getCodexHome(),
cursorConfig: path.join(env("XDG_CONFIG_HOME") || path.join(h, ".config"), "cursor"),
cursor: home ? path.join(home, ".cursor") : getCursorDir(),
grok: env("GROK_HOME") || path.join(h, ".grok"),
kimi: env("KIMI_CODE_HOME") || path.join(h, ".kimi-code"),
opencode: path.join(env("XDG_DATA_HOME") || path.join(h, ".local", "share"), "opencode"),
};
}
@@ -480,160 +456,6 @@ async function antigravityQuota() {
}
}
const isoOf = (v) => {
const ms = Date.parse(v);
return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
};
/** A Bearer GET to a tool's own host: its windows, or the row's error. */
async function bearerWindows(who, url, token, headers, toWindows) {
try {
const res = await fetch(url, {
headers: { Accept: "application/json", Authorization: `Bearer ${token}`, ...headers },
signal: AbortSignal.timeout(TIMEOUT_MS),
});
if (!res.ok) {
const expired = res.status === 401 || res.status === 403;
return { ...who, windows: [], error: expired ? "ETOKENEXPIRED" : "EUPSTREAM" };
}
return { ...who, ...toWindows(await res.json()) };
} catch {
return { ...who, windows: [], error: "EUPSTREAM" };
}
}
/** Grok Build's `auth.x.ai` session and its billing view. */
async function grokQuota(dirs = toolDirs()) {
const sessions = readJson(path.join(dirs.grok, "auth.json"));
if (!sessions || typeof sessions !== "object") return null;
const xai = Object.entries(sessions)
.filter(([issuer, s]) => /^https:\/\/auth\.x\.ai(::|$)/.test(issuer) && s?.key)
.map(([, s]) => s);
const live = (s) => !s.expires_at || fresh(Date.parse(s.expires_at));
const s = xai.find(live) || xai[0];
if (!s) return null;
const who = {
id: "grok",
active: true,
account: s.email || null,
plan: null,
expiresAt: s.expires_at ? isoOf(s.expires_at) : null,
};
if (!live(s)) return { ...who, windows: [], error: "ETOKENEXPIRED" };
const headers = {
"X-XAI-Token-Auth": "xai-grok-cli",
...(s.user_id && { "x-userid": String(s.user_id) }),
};
return bearerWindows(who, GROK_BILLING_URL, s.key, headers, (body) => {
const c = body?.config || body || {};
const windows = [];
// ponytail: a reply with no percent shows no week; Orca infers 0 from
// the period fields — add that if a real account ever answers so.
if (typeof c.creditUsagePercent === "number") {
windows.push({
label: "wk",
usedPercent: c.creditUsagePercent,
resetsAt: isoOf(c.currentPeriod?.end ?? c.billingPeriodEnd),
});
}
const limit = Number(c.monthlyLimit?.val);
const used = Number(c.used?.val);
if (limit > 0 && Number.isFinite(used)) {
windows.push({
label: "mo",
usedPercent: (used / limit) * 100,
resetsAt: isoOf(c.billingPeriodEnd),
});
}
return { plan: c.subscriptionTier || null, windows };
});
}
/** Kimi Code's OAuth token and its managed-plan usage. */
async function kimiQuota(dirs = toolDirs()) {
const cred = readJson(path.join(dirs.kimi, "credentials", "kimi-code.json"));
if (typeof cred?.access_token !== "string" || !cred.access_token) return null;
const exp = Number(cred.expires_at) * 1000;
const who = { id: "kimi", active: true, expiresAt: exp ? iso(exp) : null };
if (exp && !fresh(exp)) return { ...who, windows: [], error: "ETOKENEXPIRED" };
return bearerWindows(who, KIMI_USAGE_URL, cred.access_token, {}, (body) => ({
windows: [
["5h", body?.usages?.limit_5h],
["wk", body?.usages?.limit_7d],
["mo", body?.usages?.limit_month_total],
]
.filter(([, u]) => u && Number.isFinite(Number(u.used_ratio)))
.map(([label, u]) => ({
label,
usedPercent: Number(u.used_ratio) * 100,
resetsAt: isoOf(u.reset_time),
})),
}));
}
/**
* A member's `opencode.db`, copied (with its WAL) from owner-checked reads
* into a private temp dir — SQLite opens by NAME, which the member could swap
* between a check and the open. The host's own is opened in place.
*/
function openCodeDbPath(dir, tmp) {
const db = path.join(dir, "opencode.db");
if (!s3Enabled() || !ownerForRead(db)) return db;
const copy = path.join(tmp(), "opencode.db");
fs.writeFileSync(copy, readFileSafe(db, null, { max: 256 << 20 })); // ponytail: whole file in memory, capped
try {
fs.writeFileSync(`${copy}-wal`, readFileSafe(`${db}-wal`, null, { max: 256 << 20 }));
} catch {
/* no WAL: everything is in the main file */
}
return copy;
}
/** opencode's `opencode-go` key: `auth.json` (v1), else `opencode.db` (v2). */
function openCodeGoKey(dirs = toolDirs()) {
const dir = dirs.opencode;
const entry = readJson(path.join(dir, "auth.json"))?.["opencode-go"];
if (entry?.type === "api" && typeof entry.key === "string" && entry.key) return entry.key;
let conn;
let tmpDir = null;
const tmp = () => (tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-opencode-")));
try {
const file = openCodeDbPath(dir, tmp);
// The private copy is writable so SQLite can replay its WAL.
conn = new Database(file, { readonly: !tmpDir, fileMustExist: true });
const rows = conn
.prepare(
"SELECT value FROM credential WHERE integration_id = 'opencode-go' ORDER BY active DESC, time_created DESC LIMIT 8"
)
.all();
for (const r of rows) {
const v = JSON.parse(r.value);
if (typeof v?.key === "string" && v.key) return v.key;
}
} catch {
/* no OpenCode 2 database, or no credential table (OpenCode 1) */
} finally {
conn?.close();
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
}
return null;
}
async function openCodeGoQuota(dirs = toolDirs()) {
const key = openCodeGoKey(dirs);
if (!key) return null;
const who = { id: "opencode", active: true, plan: "Go" };
return bearerWindows(who, OPENCODE_GO_URL, key, {}, (body) => ({
windows: [
["5h", body?.usage?.rolling],
["wk", body?.usage?.weekly],
["mo", body?.usage?.monthly],
]
.filter(([, m]) => typeof m?.percent === "number")
.map(([label, m]) => ({ label, usedPercent: m.percent, resetsAt: isoOf(m.resetsAt) })),
}));
}
/**
* Every tool's quota row. `home`: a member's Linux home (S3) — that account's
* logins only, and no agy; omitted, the host's.
@@ -641,23 +463,12 @@ async function openCodeGoQuota(dirs = toolDirs()) {
async function quotas({ force = false, home = null } = {}) {
const dirs = toolDirs(home);
const key = (k) => (home ? `${home}|${k}` : k);
const [claude, cursor, antigravity, grok, kimi, opencode] = await Promise.all([
const [claude, cursor, antigravity] = await Promise.all([
claudeQuotas(force, home),
cached(key("cursor"), force, () => cursorQuota(dirs)),
home ? null : cached("antigravity", force, antigravityQuota),
cached(key("grok"), force, () => grokQuota(dirs)),
cached(key("kimi"), force, () => kimiQuota(dirs)),
cached(key("opencode"), force, () => openCodeGoQuota(dirs)),
]);
const tools = [
...claude,
codexQuota(dirs.codex),
antigravity,
cursor,
grok,
kimi,
opencode,
].filter(Boolean);
const tools = [...claude, codexQuota(dirs.codex), antigravity, cursor].filter(Boolean);
return { tools, checkedAt: new Date().toISOString() };
}
+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 };
+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: {
+4
View File
@@ -75,6 +75,9 @@ router.get(
"/",
wrap((req, res) => {
const q = req.query;
// A skill follows its owner's folder: take what changed there first
// (throttled), and tell every open Hub page.
for (const row of artifacts.syncSkillSources()) changed(row);
res.json({
items: artifacts.list(req, {
scope: q.scope,
@@ -358,6 +361,7 @@ router.post(
wrap(async (req, res) => {
const result = await artifacts.install(req, req.params.id, {
replace: req.body?.replace === true,
update: req.body?.update === true,
});
res.json(result);
})
+27 -28
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),
@@ -1463,12 +1473,13 @@ function antigravityHookInput(hookType, data) {
}
/**
* A Grok Build (`grok`) hook. Grok runs the hooks in `~/.claude/settings.json`
* itself (its Claude compatibility, on by default), so AgentHub's own Claude
* hook reaches here with no `tool` — told apart by the camelCase keys only
* Grok sends beside Claude's snake_case aliases (`hookEventName`,
* `workspaceRoot`). Its `transcript_path` is Grok's file, never a Claude
* transcript, so it is dropped before anything could parse it.
* A Grok Build hook. Grok is not an AgentHub tool, but it runs the hooks in
* `~/.claude/settings.json` itself (its Claude compatibility, on by default),
* so AgentHub's own Claude hook still reaches here from a Grok the user
* installed, with no `tool` — told apart by the camelCase keys only Grok sends
* beside Claude's snake_case aliases (`hookEventName`, `workspaceRoot`) and
* dropped, or it would land as a Claude session whose `transcript_path` is
* Grok's file.
*/
const isGrokPayload = (data) =>
!!data &&
@@ -1476,18 +1487,9 @@ const isGrokPayload = (data) =>
typeof data.hookEventName === "string" &&
typeof data.workspaceRoot === "string";
function grokHookInput(hookType, data) {
if (typeof data.session_id !== "string" || !data.session_id) return null;
const d = { ...data };
delete d.transcript_path;
delete d.agent_transcript_path;
return { hookType, data: d };
}
const HOOK_TOOLS = {
codex: codexHookInput,
antigravity: antigravityHookInput,
grok: grokHookInput,
};
/**
@@ -1554,14 +1556,11 @@ function ingestHook(body, pushSource = null, peer = { kind: "host" }) {
}
const ignored = { status: 200, body: { ok: true, ignored: true } };
// Which CLI sent it: `codex` from Codex's hooks (codex-hooks.js),
// `antigravity` from agy's (antigravity-hooks.js), `grok` for Grok running
// the Claude hooks (by its payload's own keys), anything else is Claude
// Code — an allowlist, never a value stored as sent.
const tool = Object.hasOwn(HOOK_TOOLS, body.tool)
? body.tool
: isGrokPayload(data)
? "grok"
: "claude";
// `antigravity` from agy's (antigravity-hooks.js), anything else is Claude
// Code — an allowlist, never a value stored as sent. Grok running the
// Claude hooks is no AgentHub tool: dropped.
const tool = Object.hasOwn(HOOK_TOOLS, body.tool) ? body.tool : "claude";
if (tool === "claude" && isGrokPayload(data)) return ignored;
if (tool !== "claude") {
if (!data || typeof data !== "object") return ignored;
require("../lib/hook-auth").dropForeignPaths(peer, data);
+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)
+16 -71
View File
@@ -843,25 +843,6 @@ router.get("/:id/transcripts", async (req, res) => {
: [];
return res.json({ transcripts: list });
}
// OpenCode: a subagent is a child session of the store, tied to the agent
// row its poller made.
if (session.tool === "opencode") {
const list = [
{ id: "main", name: "Main Agent", type: "main", has_transcript: true, db_agent_id: null },
];
for (const sub of require("../lib/opencode-usage").subagentSessions(req.params.id)) {
list.push({
id: sub.id,
name: sub.name,
type: "subagent",
subagent_type: "task",
has_transcript: true,
db_agent_id: stmts.getAgent.get(sub.rowId) ? sub.rowId : null,
});
}
return res.json({ transcripts: list });
}
const result = [];
// Query database agent list for db_agent_id association
@@ -1101,20 +1082,6 @@ router.get("/:id/transcript", async (req, res) => {
const isCursor = session.tool === "cursor";
// An agy conversation's step log (`transcript_full.jsonl`), found by antigravity-usage.js.
const isAgy = session.tool === "antigravity";
// An OpenCode conversation lives in its SQLite store, not a JSONL file: read
// whole (one session's rows), then paged like the files below.
if (session.tool === "opencode") {
let all = null;
try {
all = require("../lib/opencode-usage").transcriptMessages(
req.params.id,
isSubagentFile ? agentId : null
);
} catch {
all = null;
}
return res.json(pageMessages(all || [], { limit, afterLine, beforeLine, offset }));
}
if (isOmp) {
jsonlPath = require("../lib/omp-usage").journalPath(req.params.id, agentId || "");
} else if (isCursor) {
@@ -1177,6 +1144,9 @@ router.get("/:id/transcript", async (req, res) => {
: (e, n) => parseMessage(e, n);
const parseMessage = createClaudeLineParser(isSubagentFile);
const claudeFile = !isCodex && !isOmp && !isCursor && !isAgy;
// The edits of the turn the page starts inside of (agent-edits.js).
const carry = claudeFile ? require("../lib/agent-edits").turnCarry() : null;
if (afterLine !== null) {
// Incremental mode: skip lines until after afterLine, collect up to limit, then stop.
@@ -1232,7 +1202,8 @@ router.get("/:id/transcript", async (req, res) => {
messages.push(message);
// Sliding window: only keep the last `limit` messages
if (messages.length > limit) {
messages.shift();
const dropped = messages.shift();
carry?.drop(dropped);
}
}
if (total > limit) hasMore = true;
@@ -1283,13 +1254,22 @@ router.get("/:id/transcript", async (req, res) => {
messages.push(message);
// Sliding window: only keep the last `limit` messages in memory
if (messages.length > limit) {
messages.shift();
const dropped = messages.shift();
carry?.drop(dropped);
}
}
// If we shifted any messages out, there are more
hasMore = total > limit;
}
// A Claude subagent's own Write/Edit calls are in ITS transcript: put the
// files on its Agent result, so the turn's "Edited N files" counts them.
let turnEdits = null;
if (claudeFile) {
await require("../lib/agent-edits").attachAgentEdits(messages, req.params.id, session.cwd);
turnEdits = await carry.result(messages, req.params.id, session.cwd);
}
const lastLine = messages.length > 0 ? messages[messages.length - 1].line : 0;
const firstLine = messages.length > 0 ? messages[0].line : 0;
@@ -1304,6 +1284,7 @@ router.get("/:id/transcript", async (req, res) => {
has_more: hasMore,
last_line: lastLine,
first_line: firstLine,
...(turnEdits ? { turn_edits: turnEdits } : {}),
});
} catch (err) {
res.json({ messages: [], total: 0, has_more: false, last_line: 0, first_line: 0 });
@@ -1578,42 +1559,6 @@ function agyMessage(entry, num) {
}
}
/**
* The transcript route's paging over a conversation already in memory (an
* OpenCode store): message N is "line" N, so `after`/`before`/`offset` and the
* default latest-`limit` window answer exactly as they do over a file.
*/
function pageMessages(all, { limit, afterLine, beforeLine, offset }) {
const lined = all.map((m, i) => ({ ...m, line: i + 1 }));
let picked;
let hasMore;
if (afterLine !== null) {
const rest = lined.filter((m) => m.line > afterLine);
picked = rest.slice(0, limit);
hasMore = rest.length > limit;
} else if (beforeLine !== null) {
const older = lined.filter((m) => m.line < beforeLine);
picked = older.slice(-limit);
hasMore = older.length > limit;
} else if (offset > 0) {
picked = lined.slice(offset, offset + limit);
hasMore = lined.length > offset + limit;
} else {
picked = lined.slice(-limit);
hasMore = lined.length > limit;
}
const lastLine = picked.length ? picked[picked.length - 1].line : 0;
const firstLine = picked.length ? picked[0].line : 0;
for (const m of picked) delete m.line;
return {
messages: picked,
total: lined.length,
has_more: hasMore,
last_line: lastLine,
first_line: firstLine,
};
}
module.exports = router;
module.exports.sessionStats = sessionStats;
// Exported for unit tests — sender attribution is correctness-critical.