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.
This commit is contained in:
2026-10-23 16:41:45 +07:00
parent 65a001dad4
commit c8bc4a95a9
54 changed files with 2521 additions and 112 deletions
+15 -5
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),
+33 -4
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 {
@@ -1315,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;
@@ -2426,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)