Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c28395402b | |||
| 7798530528 | |||
| 25f63d9ad3 | |||
| d060d288fb | |||
| 6099841cef |
@@ -1,4 +1,8 @@
|
|||||||
{
|
{
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_BASE_URL": "",
|
||||||
|
"ANTHROPIC_AUTH_TOKEN": ""
|
||||||
|
},
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(npm run:*)",
|
"Bash(npm run:*)",
|
||||||
@@ -36,11 +40,8 @@
|
|||||||
"Bash(node -e ':*)",
|
"Bash(node -e ':*)",
|
||||||
"Bash(node -e \"require\\('./server/lib/transcript-cache'\\); require\\('./scripts/import-history'\\); console.log\\('All modules load OK'\\)\")",
|
"Bash(node -e \"require\\('./server/lib/transcript-cache'\\); require\\('./scripts/import-history'\\); console.log\\('All modules load OK'\\)\")",
|
||||||
"Bash(claude --version)",
|
"Bash(claude --version)",
|
||||||
"Bash(gh pr *)"
|
"Bash(gh pr *)",
|
||||||
|
"Bash(grep *)"
|
||||||
]
|
]
|
||||||
},
|
|
||||||
"env": {
|
|
||||||
"ANTHROPIC_BASE_URL": "",
|
|
||||||
"ANTHROPIC_AUTH_TOKEN": ""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Claude Code Working Guide
|
# Claude Code Working Guide
|
||||||
|
|
||||||
_Last synced: v2.8.39 (2026-10-29) — a display name may not read as another account's (ENAMETAKEN)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
|
_Last synced: v2.8.40 (2026-10-29) — a Codex profile switch restarts Codex's app-server daemon so sessions follow._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
|
||||||
|
|
||||||
## Project mission
|
## Project mission
|
||||||
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,8 @@
|
|||||||
* @description Tests for the sidebar's workspace list: one flat row per
|
* @description Tests for the sidebar's workspace list: one flat row per
|
||||||
* workspace in the stored order, a row expanding to its sessions (remembered),
|
* workspace in the stored order, a row expanding to its sessions (remembered),
|
||||||
* a live session opening its pane and an ended one its transcript, the
|
* a live session opening its pane and an ended one its transcript, the
|
||||||
* workspace's OWN sessions only, a session's state dot, rename and archive,
|
* workspace's OWN sessions only, a session's state dot, rename and archive
|
||||||
|
* (a live one's pane stopped first),
|
||||||
* selection
|
* selection
|
||||||
* by URL, Alt+arrow reordering kept in localStorage, double-click rename, the
|
* by URL, Alt+arrow reordering kept in localStorage, double-click rename, the
|
||||||
* search, the row menu's items by kind and tool, the status dot, and the
|
* search, the row menu's items by kind and tool, the status dot, and the
|
||||||
@@ -225,6 +226,8 @@ describe("WorkspaceTree", () => {
|
|||||||
await user.type(input, "Better name{Enter}");
|
await user.type(input, "Better name{Enter}");
|
||||||
expect(api.sessions.patch).toHaveBeenCalledWith("s-old", { name: "Better name" });
|
expect(api.sessions.patch).toHaveBeenCalledWith("s-old", { name: "Better name" });
|
||||||
await user.click(screen.getByTestId("workspace-session-archive-s-live"));
|
await user.click(screen.getByTestId("workspace-session-archive-s-live"));
|
||||||
|
// A live session's Archive stops its pane first — never a hidden pane left running.
|
||||||
|
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2", { archive: true });
|
||||||
expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { archived: true });
|
expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { archived: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
* a catalog tool's bypass — a YOLO chip for Codex) and Local / New worktree — the workspace's Recents — the sessions it
|
* a catalog tool's bypass — a YOLO chip for Codex) and Local / New worktree — the workspace's Recents — the sessions it
|
||||||
* owns and has not archived (`?lane=&archived=0`, with each one's state dot,
|
* owns and has not archived (`?lane=&archived=0`, with each one's state dot,
|
||||||
* **Show more** past the first page, and the sidebar row's Rename/Archive in
|
* **Show more** past the first page, and the sidebar row's Rename/Archive in
|
||||||
* its right-click menu), then the archived ones folded with Unarchive; a live session opens its pane,
|
* its right-click menu — Archive on a live session stops its pane first, `endRuns`), then the archived ones folded with Unarchive; a live session opens its pane,
|
||||||
* any other its conversation (`&session=`, `SessionReplay`), nothing is resumed — and on the right its context: Instructions, Memory (read-only), Context files and
|
* any other its conversation (`&session=`, `SessionReplay`), nothing is resumed — and on the right its context: Instructions, Memory (read-only), Context files and
|
||||||
* Folders (`WorkspaceContext.tsx`, also folded at the bottom of the Changes panel).
|
* Folders (`WorkspaceContext.tsx`, also folded at the bottom of the Changes panel).
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
|
|
||||||
import { isSubmitEnter } from "../../lib/keys";
|
import { isSubmitEnter } from "../../lib/keys";
|
||||||
import { confirmDialog } from "../../lib/confirm";
|
import { confirmDialog } from "../../lib/confirm";
|
||||||
|
import { endRuns } from "../../lib/stopSession";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
@@ -307,30 +308,30 @@ function HomeBody({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const archiveSession = async (s: Session) => {
|
const archiveSession = async (s: Session) => {
|
||||||
const live = !!liveRunFor(lane, liveRuns, s.id);
|
const run = liveRunFor(lane, liveRuns, s.id);
|
||||||
|
// A live one is stopped first, as the tab's X does (its own confirm):
|
||||||
|
// archiving only its row left a pane running that no list showed.
|
||||||
|
if (run) {
|
||||||
|
if ((await endRuns([run], { name: s.name || t("tree.newSession"), t })).length)
|
||||||
|
await patchSession(s, { archived: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const wt = sessionWorktreeName(lane, s);
|
const wt = sessionWorktreeName(lane, s);
|
||||||
const ok = await confirmDialog(
|
const ok = await confirmDialog(
|
||||||
wt && !live
|
wt
|
||||||
? {
|
? {
|
||||||
title: t("tree.archiveWorktreeTitle"),
|
title: t("tree.archiveWorktreeTitle"),
|
||||||
message: t("tree.archiveWorktreeBody", { name: wt }),
|
message: t("tree.archiveWorktreeBody", { name: wt }),
|
||||||
confirmLabel: t("tree.archiveWorktreeConfirm"),
|
confirmLabel: t("tree.archiveWorktreeConfirm"),
|
||||||
}
|
}
|
||||||
: live
|
: {
|
||||||
? {
|
title: t("tree.archiveSession"),
|
||||||
title: t("tree.archiveLiveTitle"),
|
confirmLabel: t("sessionMenu.archive"),
|
||||||
message: t("tree.archiveLiveBody"),
|
destructive: false,
|
||||||
confirmLabel: t("sessionMenu.archive"),
|
}
|
||||||
destructive: false,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
title: t("tree.archiveSession"),
|
|
||||||
confirmLabel: t("sessionMenu.archive"),
|
|
||||||
destructive: false,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
if (wt && !live) {
|
if (wt) {
|
||||||
try {
|
try {
|
||||||
await api.lanes.removeSessionWorktree(lane.id, s.id).catch((err: unknown) => {
|
await api.lanes.removeSessionWorktree(lane.id, s.id).catch((err: unknown) => {
|
||||||
if ((err as { code?: string }).code !== "ENOWORKTREE") throw err;
|
if ((err as { code?: string }).code !== "ENOWORKTREE") throw err;
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
* The list shows `SESSION_LIMIT` rows and a **See all (N more)** under them that
|
* The list shows `SESSION_LIMIT` rows and a **See all (N more)** under them that
|
||||||
* loads every one, then a **Show less** that goes back to `SESSION_LIMIT`. A
|
* loads every one, then a **Show less** that goes back to `SESSION_LIMIT`. A
|
||||||
* live tab whose session was archived is not brought back as a New session,
|
* live tab whose session was archived is not brought back as a New session,
|
||||||
* and archiving a live one asks first. F2 renames the focused row too; each
|
* and archiving a live one STOPS it first (`endRuns`, the tab X's confirm). F2 renames the focused row too; each
|
||||||
* dot carries its meaning as screen-reader text. `readOnly` (another user's
|
* dot carries its meaning as screen-reader text. `readOnly` (another user's
|
||||||
* workspace, for an analyst): no live tabs, no rename/archive/menu, and a row
|
* workspace, for an analyst): no live tabs, no rename/archive/menu, and a row
|
||||||
* opens the session's analysis page (`/sessions/<id>`) — never a pane or a resume.
|
* opens the session's analysis page (`/sessions/<id>`) — never a pane or a resume.
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState, type ReactNode } from "react";
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
import { confirmDialog } from "../../lib/confirm";
|
import { endRuns } from "../../lib/stopSession";
|
||||||
import { isSubmitEnter } from "../../lib/keys";
|
import { isSubmitEnter } from "../../lib/keys";
|
||||||
import { Archive, GitBranch, Pin, PinOff, SquareTerminal } from "lucide-react";
|
import { Archive, GitBranch, Pin, PinOff, SquareTerminal } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -363,17 +363,10 @@ export function WorkspaceSessionList({
|
|||||||
};
|
};
|
||||||
const archive = async (s: Session) => {
|
const archive = async (s: Session) => {
|
||||||
if (sessionWorktreeName(lane, s) && !runFor(s)) return setArchiving(s);
|
if (sessionWorktreeName(lane, s) && !runFor(s)) return setArchiving(s);
|
||||||
// A live one keeps running in its tab; archiving only hides its row.
|
// A live one is stopped first, as the tab's X does: archiving only its row
|
||||||
if (
|
// left a pane running that no list showed any more.
|
||||||
runFor(s) &&
|
const run = runFor(s);
|
||||||
!(await confirmDialog({
|
if (run && !(await endRuns([run], { name: s.name || t("tree.newSession"), t })).length) return;
|
||||||
title: t("tree.archiveLiveTitle"),
|
|
||||||
message: t("tree.archiveLiveBody"),
|
|
||||||
confirmLabel: t("sessionMenu.archive"),
|
|
||||||
destructive: false,
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
await patch(s, { archived: true });
|
await patch(s, { archived: true });
|
||||||
};
|
};
|
||||||
const archiveWithWorktree = async (s: Session) => {
|
const archiveWithWorktree = async (s: Session) => {
|
||||||
|
|||||||
@@ -468,7 +468,8 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
// A terminal mounted on a hidden tab sits in a 0×0 box (`display:none`):
|
// A terminal mounted on a hidden tab sits in a 0×0 box (`display:none`):
|
||||||
// fitting it there shrinks xterm to its 12×6 minimum, and that size would
|
// fitting it there shrinks xterm to its 12×6 minimum, and that size would
|
||||||
// reach tmux with the attach. It stays unfitted until it is shown.
|
// reach tmux with the attach. It stays unfitted until it is shown.
|
||||||
const hasBox = () => !!containerRef.current?.clientWidth && !!containerRef.current.clientHeight;
|
const hasBox = () =>
|
||||||
|
!!containerRef.current?.clientWidth && !!containerRef.current.clientHeight;
|
||||||
if (hasBox()) fit.fit();
|
if (hasBox()) fit.fit();
|
||||||
// Take the keyboard as soon as the pane is on screen: the terminal IS the
|
// Take the keyboard as soon as the pane is on screen: the terminal IS the
|
||||||
// page here, and having to click it first meant the first thing typed went
|
// page here, and having to click it first meant the first thing typed went
|
||||||
@@ -761,6 +762,16 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
refit();
|
refit();
|
||||||
};
|
};
|
||||||
document.fonts?.addEventListener("loadingdone", onFontLoaded);
|
document.fonts?.addEventListener("loadingdone", onFontLoaded);
|
||||||
|
// The browser may drop or reuse the WebGL glyph atlas's GPU memory without
|
||||||
|
// a context-loss event (sleep/resume, a long-backgrounded tab, a display or
|
||||||
|
// zoom change on macOS): xterm keeps slicing the old coordinates and every
|
||||||
|
// cell shows a clipped or foreign glyph while the buffer stays correct.
|
||||||
|
// Rebuilding the atlas when the page comes back is cheap and redraws clean.
|
||||||
|
const onPageShown = () => {
|
||||||
|
if (document.visibilityState === "visible") term.clearTextureAtlas();
|
||||||
|
};
|
||||||
|
document.addEventListener("visibilitychange", onPageShown);
|
||||||
|
window.addEventListener("focus", onPageShown);
|
||||||
// Settings → Appearance changed the terminal's font or size: an open
|
// Settings → Appearance changed the terminal's font or size: an open
|
||||||
// pane takes it now, re-measured and re-fitted like a font landing.
|
// pane takes it now, re-measured and re-fitted like a font landing.
|
||||||
const onFontPref = () => {
|
const onFontPref = () => {
|
||||||
@@ -779,6 +790,8 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
searchRef.current = null;
|
searchRef.current = null;
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
document.fonts?.removeEventListener("loadingdone", onFontLoaded);
|
document.fonts?.removeEventListener("loadingdone", onFontLoaded);
|
||||||
|
document.removeEventListener("visibilitychange", onPageShown);
|
||||||
|
window.removeEventListener("focus", onPageShown);
|
||||||
window.removeEventListener(FONT_SIZE_EVENT, onFontPref);
|
window.removeEventListener(FONT_SIZE_EVENT, onFontPref);
|
||||||
term.textarea?.removeEventListener("focus", reassert);
|
term.textarea?.removeEventListener("focus", reassert);
|
||||||
detachIme();
|
detachIme();
|
||||||
|
|||||||
@@ -262,11 +262,11 @@ function LabelEdit({
|
|||||||
function useRunner(load: () => Promise<unknown>) {
|
function useRunner(load: () => Promise<unknown>) {
|
||||||
const [busy, setBusy] = useState<string | null>(null);
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
const run = useCallback(
|
const run = useCallback(
|
||||||
async (key: string, fn: () => Promise<unknown>, success: string) => {
|
async <T,>(key: string, fn: () => Promise<T>, success: string | ((r: T) => string)) => {
|
||||||
setBusy(key);
|
setBusy(key);
|
||||||
try {
|
try {
|
||||||
await fn();
|
const r = await fn();
|
||||||
toast.success(success);
|
toast.success(typeof success === "function" ? success(r) : success);
|
||||||
await load();
|
await load();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
toast.error(err);
|
toast.error(err);
|
||||||
@@ -399,7 +399,14 @@ function OtherProfiles({
|
|||||||
void run(
|
void run(
|
||||||
`switch:${p.name}`,
|
`switch:${p.name}`,
|
||||||
() => api.toolProfiles.activate(tool, p.name),
|
() => api.toolProfiles.activate(tool, p.name),
|
||||||
t("toolProfiles.switchSuccess", { name: p.label, tool: label })
|
(r) =>
|
||||||
|
t(
|
||||||
|
r.following ? "toolProfiles.switchFollowing" : "toolProfiles.switchSuccess",
|
||||||
|
{
|
||||||
|
name: p.label,
|
||||||
|
tool: label,
|
||||||
|
}
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
className={btnCls}
|
className={btnCls}
|
||||||
@@ -800,9 +807,7 @@ function ProfileUsageLine({
|
|||||||
|
|
||||||
/** Org / seat / plan line shared by the active card and each profile row. */
|
/** Org / seat / plan line shared by the active card and each profile row. */
|
||||||
function IdentityLine({ identity }: { identity: CcAccountIdentity }) {
|
function IdentityLine({ identity }: { identity: CcAccountIdentity }) {
|
||||||
const parts = [identity.organization, identity.plan || identity.subscriptionType].filter(
|
const parts = [identity.organization, identity.plan || identity.subscriptionType].filter(Boolean);
|
||||||
Boolean
|
|
||||||
);
|
|
||||||
if (parts.length === 0) return null;
|
if (parts.length === 0) return null;
|
||||||
return <div className="text-[10px] text-fg-muted mt-0.5">{parts.join(" · ")}</div>;
|
return <div className="text-[10px] text-fg-muted mt-0.5">{parts.join(" · ")}</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -505,8 +505,6 @@
|
|||||||
"changes.loadMore": "Load more commits",
|
"changes.loadMore": "Load more commits",
|
||||||
"changes.commentPasted": "Pasted into tab {{tab}} — press Enter there to send it",
|
"changes.commentPasted": "Pasted into tab {{tab}} — press Enter there to send it",
|
||||||
"changes.splitter": "Resize the changes panel",
|
"changes.splitter": "Resize the changes panel",
|
||||||
"tree.archiveLiveTitle": "Archive a live session?",
|
|
||||||
"tree.archiveLiveBody": "Its tab keeps running; archiving only hides it from this list. Unarchive it from the archived fold.",
|
|
||||||
"tree.state.awaiting": "waiting for you",
|
"tree.state.awaiting": "waiting for you",
|
||||||
"tree.state.error": "error",
|
"tree.state.error": "error",
|
||||||
"tree.state.live": "live",
|
"tree.state.live": "live",
|
||||||
|
|||||||
@@ -1396,6 +1396,7 @@
|
|||||||
"noActive": "{{tool}} holds no login on this machine.",
|
"noActive": "{{tool}} holds no login on this machine.",
|
||||||
"noProfiles": "Could not load the profiles.",
|
"noProfiles": "Could not load the profiles.",
|
||||||
"switchSuccess": "Switched {{tool}} to \"{{name}}\". Start a new session to use it.",
|
"switchSuccess": "Switched {{tool}} to \"{{name}}\". Start a new session to use it.",
|
||||||
|
"switchFollowing": "Switched {{tool}} to \"{{name}}\" — running sessions follow it.",
|
||||||
"deleteConfirmBody": "This removes the saved {{tool}} login. To use it again, sign in from a terminal and save it once more.",
|
"deleteConfirmBody": "This removes the saved {{tool}} login. To use it again, sign in from a terminal and save it once more.",
|
||||||
"loginHint": "Add another {{tool}} login by signing in from a terminal first:",
|
"loginHint": "Add another {{tool}} login by signing in from a terminal first:",
|
||||||
"subtitle": {
|
"subtitle": {
|
||||||
|
|||||||
@@ -505,8 +505,6 @@
|
|||||||
"changes.loadMore": "Tải thêm commit",
|
"changes.loadMore": "Tải thêm commit",
|
||||||
"changes.commentPasted": "Đã dán vào tab {{tab}} — nhấn Enter ở đó để gửi",
|
"changes.commentPasted": "Đã dán vào tab {{tab}} — nhấn Enter ở đó để gửi",
|
||||||
"changes.splitter": "Đổi độ rộng khung thay đổi",
|
"changes.splitter": "Đổi độ rộng khung thay đổi",
|
||||||
"tree.archiveLiveTitle": "Lưu trữ một session đang chạy?",
|
|
||||||
"tree.archiveLiveBody": "Tab của nó vẫn chạy; lưu trữ chỉ ẩn nó khỏi danh sách này. Bỏ lưu trữ trong mục đã lưu trữ.",
|
|
||||||
"tree.state.awaiting": "đang chờ bạn",
|
"tree.state.awaiting": "đang chờ bạn",
|
||||||
"tree.state.error": "lỗi",
|
"tree.state.error": "lỗi",
|
||||||
"tree.state.live": "đang chạy",
|
"tree.state.live": "đang chạy",
|
||||||
|
|||||||
@@ -1384,6 +1384,7 @@
|
|||||||
"noActive": "{{tool}} chưa có đăng nhập nào trên máy này.",
|
"noActive": "{{tool}} chưa có đăng nhập nào trên máy này.",
|
||||||
"noProfiles": "Không tải được danh sách hồ sơ.",
|
"noProfiles": "Không tải được danh sách hồ sơ.",
|
||||||
"switchSuccess": "Đã chuyển {{tool}} sang \"{{name}}\". Mở phiên mới để dùng.",
|
"switchSuccess": "Đã chuyển {{tool}} sang \"{{name}}\". Mở phiên mới để dùng.",
|
||||||
|
"switchFollowing": "Đã chuyển {{tool}} sang \"{{name}}\" — các phiên đang chạy theo luôn.",
|
||||||
"deleteConfirmBody": "Thao tác này xoá đăng nhập {{tool}} đã lưu. Muốn dùng lại, hãy đăng nhập từ terminal và lưu lại lần nữa.",
|
"deleteConfirmBody": "Thao tác này xoá đăng nhập {{tool}} đã lưu. Muốn dùng lại, hãy đăng nhập từ terminal và lưu lại lần nữa.",
|
||||||
"loginHint": "Thêm đăng nhập {{tool}} khác bằng cách đăng nhập từ terminal trước:",
|
"loginHint": "Thêm đăng nhập {{tool}} khác bằng cách đăng nhập từ terminal trước:",
|
||||||
"subtitle": {
|
"subtitle": {
|
||||||
|
|||||||
@@ -1401,7 +1401,7 @@ export const api = {
|
|||||||
),
|
),
|
||||||
/** POST /api/tool-profiles/:tool/activate — the outgoing login is saved back first. */
|
/** POST /api/tool-profiles/:tool/activate — the outgoing login is saved back first. */
|
||||||
activate: (tool: string, name: string) =>
|
activate: (tool: string, name: string) =>
|
||||||
request<{ ok: true; name: string; savedOutgoing: string | null }>(
|
request<{ ok: true; name: string; savedOutgoing: string | null; following?: boolean }>(
|
||||||
`/tool-profiles/${encodeURIComponent(tool)}/activate`,
|
`/tool-profiles/${encodeURIComponent(tool)}/activate`,
|
||||||
{ method: "POST", body: JSON.stringify({ name }) }
|
{ method: "POST", body: JSON.stringify({ name }) }
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
* message. Both ask first when any pane holds an AI agent (it may be
|
* message. Both ask first when any pane holds an AI agent (it may be
|
||||||
* mid-turn); a plain shell holds no session, so it neither asks nor sleeps.
|
* mid-turn); a plain shell holds no session, so it neither asks nor sleeps.
|
||||||
* Used by the tab bar (`ConsoleTabs`), the split panes (`Workspace`) and a
|
* Used by the tab bar (`ConsoleTabs`), the split panes (`Workspace`) and a
|
||||||
* session's right-click menu (`SessionMenu`).
|
* session's right-click menu (`SessionMenu`), and a live session's Archive
|
||||||
|
* (`WorkspaceSessionList`, `WorkspaceHome`) — archiving never leaves a pane running.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
|||||||
<span
|
<span
|
||||||
class="text-status-warning/90"
|
class="text-status-warning/90"
|
||||||
>
|
>
|
||||||
UI build v2.8.39 (rebuild client to match)
|
UI build v2.8.40 (rebuild client to match)
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-1
@@ -3036,7 +3036,7 @@ POST /api/tool-profiles/:tool/activate Body: { name }
|
|||||||
DELETE /api/tool-profiles/:tool Body: { name }
|
DELETE /api/tool-profiles/:tool Body: { name }
|
||||||
```
|
```
|
||||||
|
|
||||||
The login profiles of the AI tools other than Claude Code (`server/lib/tool-profiles.js`; Claude's are `/api/cc-config/profiles` above). Settings → AI tools → **Profiles** shows both, one tab per tool. `:tool` is `codex` (the whole `$CODEX_HOME/auth.json`: a ChatGPT login or an API key) or `omp` (every credential Oh My Pi stores, read with `omp auth export` and written back with `omp auth import -` — secrets on stdin, never argv; import merges, so a switch first empties omp's credential tables in `~/.omp/agent/agent.db`, and a failed import puts the outgoing set back). `antigravity` is listed with `supported: false` and a `reason`: agy keeps its login in the OS keyring, which AgentHub does not touch (`409 EUNSUPPORTED`). `GET /` answers `{tools: [{tool, supported, reason?, items, active, error?}]}`, each item `{name, label, savedAt, active, kind, email, detail}` — never a token; one tool's failure (omp not installed) is that tool's `error`, not the request's. Profiles are stored one file each under `<CLAUDE_HOME>/agenthub-profiles/<tool>/` (0700/0600) with cc-profiles' rules: one login is one profile (`409 EDUPLICATE`), a switch re-saves the outgoing login into its own profile first and refuses one never saved (`409 EUNSAVED`), the live login's profile is not deletable (`409 EACTIVE`), `409 ENOAUTH` when the tool holds no login, `409 ENOTINSTALLED`, `502 EOMP` when omp's own command fails. Writes are same-origin only; in server mode `/tool-profiles` is in `ADMIN_WRITE_PREFIXES` and `STAFF_ONLY_PREFIXES` — it changes the HOST's own logins.
|
The login profiles of the AI tools other than Claude Code (`server/lib/tool-profiles.js`; Claude's are `/api/cc-config/profiles` above). Settings → AI tools → **Profiles** shows both, one tab per tool. `:tool` is `codex` (the whole `$CODEX_HOME/auth.json`: a ChatGPT login or an API key) or `omp` (every credential Oh My Pi stores, read with `omp auth export` and written back with `omp auth import -` — secrets on stdin, never argv; import merges, so a switch first empties omp's credential tables in `~/.omp/agent/agent.db`, and a failed import puts the outgoing set back). `antigravity` is listed with `supported: false` and a `reason`: agy keeps its login in the OS keyring, which AgentHub does not touch (`409 EUNSUPPORTED`). `GET /` answers `{tools: [{tool, supported, reason?, items, active, error?}]}`, each item `{name, label, savedAt, active, kind, email, detail}` — never a token; one tool's failure (omp not installed) is that tool's `error`, not the request's. Profiles are stored one file each under `<CLAUDE_HOME>/agenthub-profiles/<tool>/` (0700/0600) with cc-profiles' rules: one login is one profile (`409 EDUPLICATE`), a switch re-saves the outgoing login into its own profile first and refuses one never saved (`409 EUNSAVED`), and for Codex then runs `codex app-server daemon restart` when that CODEX_HOME's shared daemon is running (its `app-server-control.sock` resolves) — the daemon holds the login it started with, so without it neither an open nor a NEW Codex session saw the switch; open TUIs reconnect on their own — answering `following: true` (the activate response's extra field; `false` when no daemon ran), the live login's profile is not deletable (`409 EACTIVE`), `409 ENOAUTH` when the tool holds no login, `409 ENOTINSTALLED`, `502 EOMP` when omp's own command fails. Writes are same-origin only; in server mode `/tool-profiles` is in `ADMIN_WRITE_PREFIXES` and `STAFF_ONLY_PREFIXES` — it changes the HOST's own logins.
|
||||||
|
|
||||||
### AI Proxy profiles
|
### AI Proxy profiles
|
||||||
|
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.39",
|
"version": "2.8.40",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.39",
|
"version": "2.8.40",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.39",
|
"version": "2.8.40",
|
||||||
"bin": {
|
"bin": {
|
||||||
"agenthub": "bin/agenthub.js"
|
"agenthub": "bin/agenthub.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -97,6 +97,33 @@ describe("tool-profiles: codex", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("restarts a running Codex daemon so open sessions follow; none running, nothing to restart", async () => {
|
||||||
|
login("acct-a", "alice@example.com");
|
||||||
|
profiles.saveProfile("codex");
|
||||||
|
login("acct-b", "bob@example.com");
|
||||||
|
profiles.saveProfile("codex");
|
||||||
|
assert.equal(profiles.activateProfile("codex", "alice").following, false);
|
||||||
|
|
||||||
|
const log = path.join(ROOT, "codex-argv.log");
|
||||||
|
const bin = path.join(ROOT, "fake-codex.sh");
|
||||||
|
fs.writeFileSync(bin, `#!/bin/sh\necho "$CODEX_HOME $*" >> '${log}'\n`, { mode: 0o755 });
|
||||||
|
const sock = path.join(process.env.CODEX_HOME, "app-server-control", "app-server-control.sock");
|
||||||
|
fs.mkdirSync(path.dirname(sock), { recursive: true });
|
||||||
|
fs.writeFileSync(sock, "");
|
||||||
|
process.env.AGENTHUB_CODEX_BIN = bin;
|
||||||
|
try {
|
||||||
|
assert.equal(profiles.activateProfile("codex", "bob").following, true);
|
||||||
|
for (let i = 0; i < 50 && !fs.existsSync(log); i++)
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(log, "utf8").trim(),
|
||||||
|
`${process.env.CODEX_HOME} app-server daemon restart`
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
delete process.env.AGENTHUB_CODEX_BIN;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("refuses to switch away from an unsaved login", () => {
|
it("refuses to switch away from an unsaved login", () => {
|
||||||
login("acct-a", "alice@example.com");
|
login("acct-a", "alice@example.com");
|
||||||
profiles.saveProfile("codex");
|
profiles.saveProfile("codex");
|
||||||
|
|||||||
@@ -93,6 +93,12 @@ describe("GET /api/sessions/:id/stats tool totals", () => {
|
|||||||
assert.equal(body.tool_call_count, 2);
|
assert.equal(body.tool_call_count, 2);
|
||||||
assert.equal(body.distinct_tool_count, 1);
|
assert.equal(body.distinct_tool_count, 1);
|
||||||
});
|
});
|
||||||
|
it("counts each call once in tools_used, not its Pre and Post events", async () => {
|
||||||
|
const one = (await get("/api/sessions/ua-1/stats")).body.tools_used;
|
||||||
|
assert.ok(one.every((t) => t.count === 1));
|
||||||
|
const two = (await get("/api/sessions/ua-2/stats")).body.tools_used;
|
||||||
|
assert.deepEqual(two, [{ tool_name: "Read", count: 2 }]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("GET /api/events labels", () => {
|
describe("GET /api/events labels", () => {
|
||||||
|
|||||||
@@ -2547,10 +2547,16 @@ const stmts = {
|
|||||||
FROM events
|
FROM events
|
||||||
WHERE session_id = ? AND tool_name IS NOT NULL
|
WHERE session_id = ? AND tool_name IS NOT NULL
|
||||||
`),
|
`),
|
||||||
|
// One row per call, like sessionToolCallTotals: a call is its PreToolUse,
|
||||||
|
// or its PostToolUse in a session that has none (imported). Counting every
|
||||||
|
// event with a tool_name counted each call twice.
|
||||||
sessionToolUsageCounts: db.prepare(`
|
sessionToolUsageCounts: db.prepare(`
|
||||||
SELECT tool_name, COUNT(*) as count
|
SELECT tool_name, COUNT(*) as count
|
||||||
FROM events
|
FROM events
|
||||||
WHERE session_id = ? AND tool_name IS NOT NULL
|
WHERE session_id = ? AND tool_name IS NOT NULL
|
||||||
|
AND event_type = CASE
|
||||||
|
WHEN EXISTS (SELECT 1 FROM events WHERE session_id = ? AND event_type = 'PreToolUse')
|
||||||
|
THEN 'PreToolUse' ELSE 'PostToolUse' END
|
||||||
GROUP BY tool_name
|
GROUP BY tool_name
|
||||||
ORDER BY count DESC
|
ORDER BY count DESC
|
||||||
LIMIT 15
|
LIMIT 15
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
* swap it back in later, touching nothing but that login.
|
* swap it back in later, touching nothing but that login.
|
||||||
*
|
*
|
||||||
* - `codex`: the whole `$CODEX_HOME/auth.json` (a ChatGPT login or an API
|
* - `codex`: the whole `$CODEX_HOME/auth.json` (a ChatGPT login or an API
|
||||||
* key). Config, sessions, skills and history stay where they are.
|
* key). Config, sessions, skills and history stay where they are. A
|
||||||
|
* switch then restarts Codex's shared app-server daemon when one runs,
|
||||||
|
* so running and new sessions follow the new login (`follow`).
|
||||||
* - `omp`: every credential Oh My Pi stores, read with `omp auth export`
|
* - `omp`: every credential Oh My Pi stores, read with `omp auth export`
|
||||||
* and put back with `omp auth import -` (secrets on stdin, never argv).
|
* and put back with `omp auth import -` (secrets on stdin, never argv).
|
||||||
* Import MERGES, so a switch first empties omp's own credential tables in
|
* Import MERGES, so a switch first empties omp's own credential tables in
|
||||||
@@ -85,6 +87,15 @@ const codex = {
|
|||||||
writeJson(codexAuthFile(), secret);
|
writeJson(codexAuthFile(), secret);
|
||||||
return [codexAuthFile()];
|
return [codexAuthFile()];
|
||||||
},
|
},
|
||||||
|
// Codex ≥ 0.164 talks to the API through a shared app-server daemon that
|
||||||
|
// outlives every TUI and holds the login it started with: a new auth.json
|
||||||
|
// reached neither a running session nor a NEW one, which kept the old
|
||||||
|
// (often out-of-quota) account. Restarting the daemon makes it read the new
|
||||||
|
// login, and every open TUI reconnects to it on its own (verified on
|
||||||
|
// 0.164.0). No daemon running: nothing to restart — the next one reads the file.
|
||||||
|
follow() {
|
||||||
|
return restartCodexDaemon();
|
||||||
|
},
|
||||||
identify(auth) {
|
identify(auth) {
|
||||||
if (auth.tokens && auth.tokens.id_token) {
|
if (auth.tokens && auth.tokens.id_token) {
|
||||||
const c = jwtClaims(auth.tokens.id_token) || {};
|
const c = jwtClaims(auth.tokens.id_token) || {};
|
||||||
@@ -107,6 +118,26 @@ const codex = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** The control socket's link points into a per-user runtime dir that exists only while a daemon runs. */
|
||||||
|
const codexDaemonSocket = () =>
|
||||||
|
path.join(getCodexHome(), "app-server-control", "app-server-control.sock");
|
||||||
|
|
||||||
|
/** Restart the CODEX_HOME's app-server daemon when one is running; true when a restart was started. */
|
||||||
|
function restartCodexDaemon() {
|
||||||
|
if (!fs.existsSync(codexDaemonSocket())) return false;
|
||||||
|
const bin = process.env.AGENTHUB_CODEX_BIN || require("./ai-tools").detect("codex").path;
|
||||||
|
if (!bin) return false;
|
||||||
|
cp.execFile(
|
||||||
|
bin,
|
||||||
|
["app-server", "daemon", "restart"],
|
||||||
|
{ env: { ...process.env, CODEX_HOME: getCodexHome() }, timeout: 60000, windowsHide: true },
|
||||||
|
(e) => {
|
||||||
|
if (e) console.warn(`[tool-profiles] codex daemon restart failed: ${e.message}`);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Oh My Pi ───────────────────────────────────────────────────────────
|
// ── Oh My Pi ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const ompDir = () => process.env.AGENTHUB_OMP_DIR || path.join(os.homedir(), ".omp");
|
const ompDir = () => process.env.AGENTHUB_OMP_DIR || path.join(os.homedir(), ".omp");
|
||||||
@@ -405,7 +436,9 @@ function activateProfile(tool, name) {
|
|||||||
}
|
}
|
||||||
const target = readProfile(tool, name);
|
const target = readProfile(tool, name);
|
||||||
const written = a.write(target.secret, live);
|
const written = a.write(target.secret, live);
|
||||||
return { ok: true, name, savedOutgoing, written };
|
// `following`: running sessions take the new login without a restart of their own.
|
||||||
|
const following = typeof a.follow === "function" ? a.follow() : false;
|
||||||
|
return { ok: true, name, savedOutgoing, written, following };
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteProfile(tool, name) {
|
function deleteProfile(tool, name) {
|
||||||
|
|||||||
@@ -489,6 +489,11 @@ const paths = {
|
|||||||
items: { type: "string" },
|
items: { type: "string" },
|
||||||
description: "Files the switch wrote.",
|
description: "Files the switch wrote.",
|
||||||
},
|
},
|
||||||
|
following: {
|
||||||
|
type: "boolean",
|
||||||
|
description:
|
||||||
|
"Codex: a running app-server daemon was restarted, so open and new sessions use the new login.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"Switched."
|
"Switched."
|
||||||
|
|||||||
@@ -702,7 +702,7 @@ router.get("/:id/file", async (req, res) => {
|
|||||||
function sessionStats(sessionId) {
|
function sessionStats(sessionId) {
|
||||||
const totalEvents = stmts.sessionEventCount.get(sessionId)?.count ?? 0;
|
const totalEvents = stmts.sessionEventCount.get(sessionId)?.count ?? 0;
|
||||||
const eventsByType = stmts.sessionEventTypeCounts.all(sessionId);
|
const eventsByType = stmts.sessionEventTypeCounts.all(sessionId);
|
||||||
const tools = stmts.sessionToolUsageCounts.all(sessionId);
|
const tools = stmts.sessionToolUsageCounts.all(sessionId, sessionId);
|
||||||
// Exact totals: `tools_used` is a top-15 list, so its sum is not the count.
|
// Exact totals: `tools_used` is a top-15 list, so its sum is not the count.
|
||||||
// A tool call is a PreToolUse; a session with none (imported) counts PostToolUse.
|
// A tool call is a PreToolUse; a session with none (imported) counts PostToolUse.
|
||||||
const toolTotals = stmts.sessionToolCallTotals.get(sessionId) || {};
|
const toolTotals = stmts.sessionToolCallTotals.get(sessionId) || {};
|
||||||
|
|||||||
Reference in New Issue
Block a user