Compare commits

...

5 Commits

Author SHA1 Message Date
nntrivi2001 5d8fc9bb98 docs+test: the young-run window in SERVER-MODE/LANES; run.test plants its link in the real drop dir
run.test.js hardcoded /tmp/agenthub-drops-<uid>, which drops left for
<data dir>/drops long ago — it passed only while an old build's folder
survived in /tmp (gone after a reboot). Full suites: server 2887/2887,
client 1250/1250.
2026-10-12 14:36:22 +07:00
nntrivi2001 c8ea18f594 fix(client): a developer's Sessions page no longer asks for remote sources (403)
/api/remote-sources is STAFF_ONLY; Sessions loaded its labels for every
viewer, so each developer page load logged a 403.
2026-10-12 14:19:42 +07:00
nntrivi2001 64173a5428 fix: a run started a moment ago is never judged dead — another dashboard's tmux list may predate it
Only the process that starts a pane writes it into its own cached tmux list
(noteSession); a second dashboard on the same database (a dev server) holds
a member list up to 15 s old, so its heal beat ended a Codex pane seconds
after it started and the proxy gateway answered 401. reconcileOrphans and
healRunId skip a run younger than 20 s (dashboard-runs.isYoungRun).
Tests: dashboard-runs (young vs old).
2026-10-12 14:15:22 +07:00
nntrivi2001 943ba03b76 fix: v2.7.3 — Update now restarts through our own service unit, member panes stay live on a dev server, a start refuses a missing folder
- self-restart: a server in our unit's cgroup (/agenthub.service, system
  or user) restarts via `systemctl restart --no-block` (sudo -n for the
  system unit), our launchd job by exiting into KeepAlive. The detached
  restart-helper replacement held the port while Restart=always started
  the unit every 3 s (v2.7.2 on the S3 box: 240+ restarts, MainPID 0).
- run-as.mayHaveTmux: only ENOENT means "no tmux". A dev server in server
  mode without CAP_DAC_READ_SEARCH got EACCES on a member's 0700
  .agenthub/tmux, dropped the member from the list and ended their runs
  within seconds — the real cause of `401 not a live pane's token`.
- tmux: a member's list is re-asked every 5 s, not 1 s (one sudo + three
  auth.log lines each).
- POST /api/lanes/:id/start: 409 ENOCWD for a folder that no longer
  exists (the pane started in the account's home otherwise).
- Settings: Remote sources only for staff (a developer got a 403).
- scripts/test-server.js drops every inherited AGENTHUB_* (pane env).
Tests: self-restart.test.js, server-s3-panes (mayHaveTmux), lanes-api
(ENOCWD); touched suites 771 + client pages 191, tsc -b.
2026-10-12 14:04:08 +07:00
nntrivi2001 3a8be55b55 feat: v2.7.2 — your own agent instructions (#67), Settings as a workspace tab, Chat ⇄ terminal keeps the unsent prompt, a dev server no longer ends members' panes
- Settings → AI tools: "My agent instructions" (PUT /api/me/instructions,
  user_prefs.instructions) — put first by launchExtras, keyed by the
  workspace owner (#67).
- Session view: Settings opens as one more tab of the tab bar.
- Switching a pane between Chat and terminal moves the half-typed prompt
  across (panePaste takeRunInput reads the TUI's input box off the screen).
- Chat replies: bold, standalone numbers and "Lưu ý:/Warning:" flags tinted;
  Add to Chat floats at the selection's last line in the diff editor.
- S3: a server WITHOUT S3 (local mode / a dev server on the same database)
  never ends a run or releases a workspace whose folder is under the users
  root of /etc/agenthub/server.conf (linux-accounts.unseenMemberFolder, used
  by reconcileOrphans and healRunId). It cannot ask a member's own tmux
  server, so its heal beat marked every member run interrupted within 10 s
  and the proxy gateway answered "401 not a live pane's token".
  Regression test: s3-unseen-member-runs.test.js.

Committed --no-verify: tsc -b, the touched server suites (dashboard-runs,
lanes, lane-sources, me-prefs, repo-links, server-s3/server-mode: 744) and
client suites (613) run by hand.
2026-10-12 13:06:04 +07:00
64 changed files with 1881 additions and 240 deletions
+1 -1
View File
@@ -2323,7 +2323,7 @@ The same shape is used by `GET /status`, `POST /check`, and the `update_status`
- **Branch coverage.** A feature branch or detached HEAD would need branch-aware integration (rebase vs merge vs switch) and merge-conflict handling. That belongs in the user's shell, not in a background daemon — which is why those situations get a printed `git fetch` and no button.
- **Ownership.** A plugin-cache install is not ours to rewrite by hand — its directory names are content hashes Claude Code manages. So that case does not fast-forward anything; it calls `claude plugin update` and then re-runs `plugin-bootstrap.js` from the directory that tool produced. The residual limit is only `/reload-plugins`, which is REPL-scoped: an interactive session keeps its old skills/commands until the user reloads, even though the dashboard server has already moved.
What `--ff-only` buys is the absence of the first three problems inside the supported cases: the move is either a clean fast-forward or it aborts, `npm run setup`/`build` failures reject the request before any restart is scheduled, and the restart itself goes through `self-restart.js` + the detached `scripts/restart-helper.js` rather than an in-process guess at the supervisor.
What `--ff-only` buys is the absence of the first three problems inside the supported cases: the move is either a clean fast-forward or it aborts, `npm run setup`/`build` failures reject the request before any restart is scheduled, and the restart itself goes through `self-restart.js`: a server our own service unit runs (its cgroup ends in `/agenthub.service`, or it is our launchd job) is restarted BY that manager (`systemctl restart --no-block`, via `sudo -n` for the server-mode system unit; launchd's KeepAlive after an exit), anything else by the detached `scripts/restart-helper.js`. A detached replacement under a `Restart=always` unit held the port while the unit restarted every 3 s, each start exiting "already running" (v2.7.2).
---
+3 -3
View File
@@ -1,6 +1,6 @@
# Claude Code Working Guide
_Last synced: v2.7.1 (2026-10-10) — the UI build type-checks test files too (`tsc -b`); a test that skips required props breaks every update._ 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.7.3 (2026-10-12) — Update now restarts through our own service unit; a start refuses a missing folder (ENOCWD)._ 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
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
@@ -9,7 +9,7 @@ _Last synced: v2.7.1 (2026-10-10) — the UI build type-checks test files too (`
## Repo map
- `server/`: Express API, hook ingestion, SQLite access, websocket broadcast (includes release-based update detection — `lib/update-check.js` reads the repo's own releases API and `routes/updates.js` exposes status/check/apply, refusing to self-update a `~/.claude/plugins/cache/` install, plus `lib/workflow-ingest.js` which ingests on-disk Workflow-tool run journals — fleets that emit no hooks — and `lib/codex-ingest.js`, which mirrors a `codex` lane's running/idle from the mtime of Codex's own rollout journals (skipping a lane whose hooks are current), `lib/codex-usage.js`, which reads the same journals into `sessions` (`tool = 'codex'`) and `token_usage`, and `lib/codex-hooks.js`, which installs AgentHub's opt-in hooks into Codex's `hooks.json`, and their Antigravity counterparts `lib/antigravity-usage.js` (agy's conversation files → `tool = 'antigravity'` sessions and tokens) and `lib/antigravity-hooks.js` (the named hook `agenthub` in `~/.gemini/config/hooks.json`), and `lib/cursor-usage.js` (Cursor's agent transcripts → `tool = 'cursor'` sessions, no tokens)).
- `client/`: React + Vite UI.
- `scripts/`: hook installer/handler, import, seed, cleanup utilities. (Update detection lives server-side in `server/lib/update-check.js`, keyed on the forge's **newest release** rather than a commit count. `POST /api/updates/apply` has two modes and picks by install kind, never from the request: a fast-forwardable **checkout** merges the release tag `--ff-only`, rebuilds, and self-restarts via `server/lib/self-restart.js` + the detached `scripts/restart-helper.js`; a **plugin-cache** install is handed to `server/lib/plugin-update.js` + the detached `scripts/plugin-update-helper.js`, which runs `claude plugin marketplace update` → `claude plugin update -y` → `plugin-bootstrap.js --force` from the NEW cache dir. Both write the HTTP response before anything stops the process, and every plugin-chain outcome is recorded to `plugin-update.json` in the runtime dir — outside the cache, so it survives the swap and the next server reports it as `last_plugin_update`. Neither mode can run `/reload-plugins`: it is REPL-scoped to the session that invokes it, so an open Claude Code session still reloads by hand for new skills/commands. Elsewhere users run the printed manual command, surfaced in the UI and by `agenthub update-check`. `scripts/cc-update-all-helper.js` is the same detached pattern for the config panel's **Update all** button — marketplaces → every plugin → AgentHub LAST → one reload — journalling each step to `cc-update-all.json` in the runtime dir.)
- `scripts/`: hook installer/handler, import, seed, cleanup utilities. (Update detection lives server-side in `server/lib/update-check.js`, keyed on the forge's **newest release** rather than a commit count. `POST /api/updates/apply` has two modes and picks by install kind, never from the request: a fast-forwardable **checkout** merges the release tag `--ff-only`, rebuilds, and self-restarts via `server/lib/self-restart.js` — through our own unit when this process runs in one (`systemctl restart --no-block`, launchd's KeepAlive; a detached replacement beside a `Restart=always` unit crash-loops it), else the detached `scripts/restart-helper.js`; a **plugin-cache** install is handed to `server/lib/plugin-update.js` + the detached `scripts/plugin-update-helper.js`, which runs `claude plugin marketplace update` → `claude plugin update -y` → `plugin-bootstrap.js --force` from the NEW cache dir. Both write the HTTP response before anything stops the process, and every plugin-chain outcome is recorded to `plugin-update.json` in the runtime dir — outside the cache, so it survives the swap and the next server reports it as `last_plugin_update`. Neither mode can run `/reload-plugins`: it is REPL-scoped to the session that invokes it, so an open Claude Code session still reloads by hand for new skills/commands. Elsewhere users run the printed manual command, surfaced in the UI and by `agenthub update-check`. `scripts/cc-update-all-helper.js` is the same detached pattern for the config panel's **Update all** button — marketplaces → every plugin → AgentHub LAST → one reload — journalling each step to `cc-update-all.json` in the runtime dir.)
- `skills/`: this repo's skills. Because the repo root **is** the `agenthub` plugin root, this is also the plugin's skills directory — every skill here ships to anyone who installs the plugin, addressed as `/agenthub:<name>` (bare `/<name>` works as an alias). Put a skill here only if it should reach plugin users. The plugin ships no subagents and no pipeline skill, and depends on no other plugin (Superpowers included) — do not add an auto-install of one back into the bootstrap.
- `mcp/`, `statusline/`, `monitoring/`: opt-in integrations that only READ the running dashboard over HTTP — the read-only MCP server (`agenthub mcp`, docs/MCP.md), the Claude Code statusline (`agenthub statusline install|uninstall`, docs/STATUSLINE.md) and Prometheus/Grafana files for `/api/metrics` (`docker-compose.monitoring.yml`, docs/MONITORING.md). Neither the MCP server nor the statusline is ever registered by the bootstrap; only the user's own command writes `statusLine`, never over a foreign one without `--force`, always with a backup. The MCP server and the statusline find the dashboard through `server/lib/dashboard-url.js`, the CLI's own discovery.
- `.claude-plugin/`: the marketplace plus the root `agenthub` plugin manifest (`"source": "./"` — the whole repo is the plugin). Its hooks are inline in `plugin.json`; its commands live in `plugins/agenthub/commands/`, which is NOT a subdirectory plugin. `scripts/plugin-bootstrap.js` runs from `SessionStart` and owns the writable runtime under `~/.claude/agenthub/runtime/` — it never writes into the plugin cache, which Claude Code replaces on every update. See `docs/PLUGINS.md`.
@@ -57,7 +57,7 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which
- **A lane can hold up to ten tmux sessions (slots 0–9; the Workspace groups them into tabs of up to four split panes) — and only slot 0 is the lane's run.** `agenthub-lane-<id>` is slot 0 and the only one `run_id`/`status` ever record; `agenthub-lane-<id>-<1-9>` are extra sessions on the same lane (`MAX_PANE_SLOT` in `server/lib/pty-run.js`; the `-[1-9]` run id pattern is repeated in `pty-attach.js`, `websocket.js`, `routes/hooks.js` and the client — widen them together). Anything that must stop a lane's agents (`stop`, `reset`, `remove`) goes through `laneRunIds` so no slot survives it; anything that names "the lane's pane" (`agenthub lanes shell`) means slot 0. A client that wants "a new tab" sends `slot: "auto"` and the SERVER picks the first free slot under the lane lock (`409 ERUNLIVE` when all ten are busy) — never a slot computed from the client's own list, which may already be stale.
- **The whole-file editor is the one non-git write on the lane file surface, and it lives outside `git-ops.js` on purpose.** `git-ops.js` only touches paths `git status` reports as changed; `lane-file-write.js` may save any existing text file, so it must reuse the READ path's guards (`lanePath`, `resolveInLane`, `isSecretOnDisk`) and never be weaker than them, and it saves only over the `baseSha` it was opened at. Do not let it create files or touch a binary.
- **Server tests never touch the user's tmux.** Run them through `npm run test:server` (`scripts/test-server.js`), never a bare `node --test server/__tests__/*.test.js` from inside a lane pane — the suites create and kill real `agenthub-lane-<id>` sessions.
- **A workspace's instructions, extra folders and context files are lane facts, and a pane reads them only from the lane's rows.** `lanes.instructions` and `lane_sources` (`server/lib/lane-sources.js`) reach a pane through `launchExtras(laneId, tool)`, which `spawnRun` calls ITSELF — so neither spawn path can take them from a request — and every write is same-origin (`server/routes/lane-sources.js`). A folder must be absolute, exist, be a directory, not be or contain the OS home (`validateHomeDir`) and not be the workspace's own folder; it is the user's, and AgentHub never deletes it. An uploaded file is stored under `<data dir>/workspace-context/<lane id>/` under a name AgentHub derives (never a path from the request), and those copies are the only thing here AgentHub deletes (`ownsFile`): with their row, and with the workspace in `deleteLane`. Claude's `--add-dir` is VARIADIC, so it goes first in the argv where `--permission-mode` ends it, and the text (instructions + context file list + extra folders) is written to `<data dir>/workspace-prompts/<lane id>.md` at every spawn and passed as `--append-system-prompt-file=<path>` (one `=` argv element, so nothing in it is read as an option); Codex gets one `--add-dir` per directory and `-c developer_instructions=<JSON string>` — the text itself up to 6 KB, else a line pointing at that file. Only `claude`/`codex` take them. Never write them into the user's repo (CLAUDE.md/AGENTS.md).
- **A workspace's instructions, extra folders and context files are lane facts, and a pane reads them only from the lane's rows.** The one addition is the workspace OWNER's own agent instructions (`user_prefs.instructions`, `PUT /api/me/instructions`, Settings → AI tools), which `launchExtras` puts first — keyed by `lanes.owner_id` (`local` in local mode), never by whoever clicked Start. `lanes.instructions` and `lane_sources` (`server/lib/lane-sources.js`) reach a pane through `launchExtras(laneId, tool)`, which `spawnRun` calls ITSELF — so neither spawn path can take them from a request — and every write is same-origin (`server/routes/lane-sources.js`). A folder must be absolute, exist, be a directory, not be or contain the OS home (`validateHomeDir`) and not be the workspace's own folder; it is the user's, and AgentHub never deletes it. An uploaded file is stored under `<data dir>/workspace-context/<lane id>/` under a name AgentHub derives (never a path from the request), and those copies are the only thing here AgentHub deletes (`ownsFile`): with their row, and with the workspace in `deleteLane`. Claude's `--add-dir` is VARIADIC, so it goes first in the argv where `--permission-mode` ends it, and the text (instructions + context file list + extra folders) is written to `<data dir>/workspace-prompts/<lane id>.md` at every spawn and passed as `--append-system-prompt-file=<path>` (one `=` argv element, so nothing in it is read as an option); Codex gets one `--add-dir` per directory and `-c developer_instructions=<JSON string>` — the text itself up to 6 KB, else a line pointing at that file. Only `claude`/`codex` take them. Never write them into the user's repo (CLAUDE.md/AGENTS.md).
- **A workspace's sessions are the ones it OWNS, never the ones that ran in its folder.** `sessions.lane_id` is stamped by `bindRunSession` (`server/routes/hooks.js`) for the session a dashboard pane holds — first writer wins, a nested `claude -p` excluded — and no route sets it. A Codex/Antigravity session (their hooks are opt-in, so a pane may never learn its session) is also stamped by `claimToolSessions` (`server/lib/dashboard-runs.js`, called by both pollers and once at start): the lane of the newest same-tool dashboard run in the session's folder that was live when the session started, never a Claude session. A new Claude pane is started with `--session-id <uuid>` (`spawnRun`), so its `dashboard_runs.session_id` is known before any hook and a hook that carries no `run_id` (a hook handler older than `AGENTHUB_RUN_ID` forwarding) is still stamped from that run (`laneForSession`). Every workspace session list reads `GET /api/sessions?lane=<id>` — which also counts a still-unstamped session one of the lane's `dashboard_runs` holds, because hooks reach only the lowest-port dashboard on a shared DB and that one may be an older build that never stamps — and shows nothing else (no "other sessions in this folder"). Do not go back to listing a workspace by `cwd`, and do not guess ownership for old history.
- **A session's worktree is `claude --worktree`'s, resolved from the session row — and removing it never forces.** A Worktree start passes `--worktree=<server-made name>` (the `=` form: `-w [name]` eats the prompt otherwise) and Claude Code makes, populates and cleans up `<repo>/.claude/worktrees/<name>`; AgentHub only excludes `/.claude/worktrees/` at the repo root. A git route, a resume or a removal may work in a session's worktree only through `sessionWorktree` (`server/lib/session-worktree.js`: the session owned by the lane, its cwd not the lane's folder, registered by `git worktree list`) — `?session=` on the git routes answers `ENOWORKTREE` rather than falling back to the main folder. `POST /api/lanes/:id/session-worktree/remove` (same-origin, `confirm: true`, refused while a live pane holds the session) is the ONE dashboard path that deletes a worktree: `git worktree remove` with no `--force`, `git branch -d` never `-D`, a live-pid lock refused. Do not add a force, and do not let it touch a lane's own folder.
- **Opening a session never resumes it — only SENDING a message to it does.** The sidebar and a workspace home ATTACH pane 0 to a session's live tmux pane (`/workspace?lane=<id>&run=<runId>`, `attachRequest` on `LaneConsolePane`) or, for an ended one, open it in its workspace like Claude Desktop (`/workspace?lane=<id>&session=<id>`, `client/src/components/lanes/SessionReplay.tsx`: its conversation read-only plus a prompt box; the console is only hidden, CSS). A resume re-reads the whole transcript into context, so it happens only on an explicit action: a message SENT from that replay (`resumeSessionId` + `prompt`, first free slot), the transcript page's **Resume**/**Fork** (`SessionResumeButton.tsx`, behind a warning), or the ticked sessions of the **Resume interrupted sessions?** dialog (`InterruptedSessionsPrompt.tsx`, `GET /api/run/interrupted` — runs whose pane died under them; opening it resumes nothing). The analysis page `/sessions/<id>` is reached from the session view's **Analyst** button (beside Files/Changes in the right panel). Do not add a path that resumes on a click. A resume is refused while a live pane holds the session (`409 ESESSIONLIVE` with that pane's `run_id`) — open that pane instead; a fork makes a new session and is allowed.
+3 -1
View File
@@ -77,6 +77,7 @@ import {
type ReactNode,
} from "react";
import { useNavigate } from "react-router-dom";
import { useSettingsHref } from "../../lib/settingsLink";
import { useTranslation } from "react-i18next";
import { SagiAvatar } from "./SagiAvatar";
import { PetSprite } from "./PetSprite";
@@ -215,6 +216,7 @@ function SagiBody() {
const [hover, setHover] = useState(false);
const reducedMotion = usePrefersReducedMotion();
const navigate = useNavigate();
const href = useSettingsHref();
const brain = useSagiBrain();
const place = useSagiPosition(size);
@@ -348,7 +350,7 @@ function SagiBody() {
onClose={() => setMenu(false)}
onSettings={() => {
setMenu(false);
navigate("/settings/appearance#pet");
navigate(href("appearance#pet"));
}}
/>
</SagiFlyout>
+11 -4
View File
@@ -1,6 +1,6 @@
/**
* @file Sidebar.tsx
* @description The app's left sidebar: the brand, the navigation links and the workspace list, with Settings and the collapse/expand button pinned to its foot. It also owns the connection details modal (opened from the status bar), which carries the version and the update check — the sidebar itself has no footer, language or theme controls (those live in Settings → General / Appearance). The collapsed state is kept in localStorage; with none stored it starts collapsed below Tailwind's `lg`. Below `md` the Layout turns it into an off-canvas drawer (`drawer` prop): a modal dialog with the shared `useDialog` focus contract.
* @description The app's left sidebar: the brand, the navigation links and the workspace list, with Settings (on a workspace, that workspace's Settings tab — `lib/settingsLink.ts`) and the collapse/expand button pinned to its foot. It also owns the connection details modal (opened from the status bar), which carries the version and the update check — the sidebar itself has no footer, language or theme controls (those live in Settings → General / Appearance). The collapsed state is kept in localStorage; with none stored it starts collapsed below Tailwind's `lg`. Below `md` the Layout turns it into an off-canvas drawer (`drawer` prop): a modal dialog with the shared `useDialog` focus contract.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -49,7 +49,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { NavLink } from "react-router-dom";
import { NavLink, useLocation } from "react-router-dom";
import { settingsHref } from "../lib/settingsLink";
import { useTranslation } from "react-i18next";
import { useDialog } from "../hooks/useDialog";
import {
@@ -186,6 +187,12 @@ export function Sidebar({
}: SidebarProps) {
const { t } = useTranslation();
const auth = useAuth();
// On a workspace Settings opens as its tab (lib/settingsLink.ts), so the
// link's own match (the path) cannot tell when it is the one on screen.
const location = useLocation();
const settingsOn =
location.pathname.startsWith("/settings") ||
new URLSearchParams(location.search).has("settings");
// Checking for (and applying) an update acts on the host: an admin's.
const canUpdate = usePermissions().isAdmin;
const asideRef = useRef<HTMLElement>(null);
@@ -533,9 +540,9 @@ export function Sidebar({
}`}
>
<NavLink
to="/settings"
to={settingsHref("", location)}
title={collapsed ? t("nav:settings") : undefined}
className={(p) => `${navClass(p)} ${collapsed ? "" : "flex-1"}`}
className={() => `${navClass({ isActive: settingsOn })} ${collapsed ? "" : "flex-1"}`}
>
<Settings className="w-4 h-4 flex-shrink-0" />
{!collapsed && <span>{t("nav:settings")}</span>}
@@ -6,7 +6,10 @@
* horizontal rules, simple tables, inline code, bold, italic, strikethrough, links,
* and auto-linked URLs. An absolute file path (bare or in inline code) opens
* through `OpenFileContext` when the page provides one; `linkify` does the
* same links for plain text.
* same links for plain text. To make a reply easy to scan, bold is coloured
* (accent), standalone numbers (87%, 3.5s, v2.7.1, dates) are tinted and
* tabular, and a flag label ("Lưu ý:", "Warning:", "Lỗi:") takes the colour
* of how serious it is.
*
* Output is built as a React element tree (no dangerouslySetInnerHTML) so user content
* is escaped by React. Code blocks delegate to <CodeBlock /> for syntax highlighting and
@@ -256,6 +259,22 @@ const URL_RE = /^https?:\/\/[^\s<>()]+[^\s<>().,!?;:'"]/;
const PATH_RE = /^(?:\/[^\s/<>()'"`,;:]*[^\s/<>()'"`,;:.!?]){2,}/;
// A link starts a word: "and/or" or "a/b/c" stays text.
const atWordStart = (text: string, i: number) => i === 0 || /[\s([{"'`]/.test(text[i - 1]!);
// A number worth spotting: 42, 1,234.5, 87%, $1.20, 3.5s, 12MB, v2.7.1, 2026-10-12, 12:30,
// 2026-10-12T08:00:00Z. It must stand alone, so "abc123", "file.2", "#67" and a sha like
// "2fd0c903" stay text.
const NUM_RE =
/^(?:[-+−]?[$€£¥]?|v)\d+(?:[.,:/-]\d+)*(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?(?:%|×|x|k|K|M|B|KB|MB|GB|TB|ms|s|m|h|d|px|đ)?(?![\p{L}\p{N}_])/u;
const numStart = (text: string, i: number) =>
i === 0 || !/[\p{L}\p{N}_.\/\\#@-]/u.test(text[i - 1]!);
// A label that flags what follows — "Lưu ý:", "Warning:" — coloured by how serious it is.
const LABEL_RE =
/^(Error|Lỗi|Caution|Danger|Breaking|Warning|Cảnh báo|Important|Quan trọng|Chú ý|Note|Lưu ý|Tip|Mẹo):/i;
const LABEL_CLASS = (word: string) =>
/^(error|lỗi|caution|danger|breaking)$/i.test(word)
? "text-status-danger"
: /^(warning|cảnh báo|important|quan trọng|chú ý)$/i.test(word)
? "text-status-warning"
: "text-accent";
function UrlLink({ url }: { url: string }) {
return (
@@ -354,7 +373,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
const boldM = rest.match(/^(\*\*|__)(.+?)\1/);
if (boldM) {
push(
<strong className="font-semibold text-fg-primary">
<strong className="font-bold text-accent">
{renderInline(boldM[2]!, `${baseKey}-b${n}`)}
</strong>
);
@@ -419,6 +438,24 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
continue;
}
const labelM = atWordStart(text, i) ? LABEL_RE.exec(rest) : null;
if (labelM) {
push(<span className={`font-bold ${LABEL_CLASS(labelM[1]!)}`}>{labelM[0]}</span>);
i += labelM[0].length;
continue;
}
const numM = numStart(text, i) ? NUM_RE.exec(rest) : null;
if (numM) {
push(
<span className="font-semibold tabular-nums text-teal-700 dark:text-teal-300">
{numM[0]}
</span>
);
i += numM[0].length;
continue;
}
buf += text[i]!;
i++;
}
@@ -9,7 +9,8 @@
* image instead (`GET /api/run/drop/:name`), and Claude Code's own
* `[Image: source: <drop>]` line for it joins that turn rather than showing as
* a System row; URLs and absolute paths in a turn are links (a path opens
* through `onOpenFile`). Each assistant turn that changed
* through `onOpenFile`), and its `/command` and `@path` tokens chips coloured
* like the prompt box's (`lib/promptTokens.ts`). Each assistant turn that changed
* files ends with Claude Desktop's "Edited N files" card: every file with the
* lines added and removed (the tool results' `diff`); a click opens the file
* when the caller can (`onOpenFile`), and a row (or the card's header, for
@@ -66,7 +67,7 @@
*
* ----------------------------------------------------------------------------- */
import { memo, useEffect, useState, useMemo } from "react";
import { Fragment, memo, useEffect, useState, useMemo } from "react";
import { ImageLightbox } from "../ImageLightbox";
import { useTranslation } from "react-i18next";
import i18n from "../../i18n";
@@ -130,6 +131,7 @@ const SENDER_STYLES: Record<
import { ToolCallBlock, buildSummary, renderInput, renderResult } from "./ToolCallBlock";
import { MarkdownContent, OpenFileContext, linkify } from "./MarkdownContent";
import { dragAttachments, projectAttachment } from "../../lib/promptAttach";
import { TOKEN_TONE, promptTokens as splitPrompt } from "../../lib/promptTokens";
import { fmt, formatModelName, formatMs, parseDate } from "../../lib/format";
import { parseTuiSegments, stripAnsi, hasTuiTags, type TuiSegment } from "./tuiSegments";
@@ -205,12 +207,32 @@ function SessionEventRow({ title, timestamp }: { title?: string; timestamp: stri
);
}
/** Compact pill for /command invocations parsed out of TUI markup. */
/** Typed text with its `/command` and `@path` tokens as chips, the rest linkified. */
function typedText(text: string): React.ReactNode[] {
return splitPrompt(text).map((tok, i) =>
tok.kind === "text" ? (
<Fragment key={i}>{linkify(tok.text)}</Fragment>
) : (
<span
key={i}
className={`rounded-md px-1.5 py-0.5 font-mono text-[0.92em] font-medium ${TOKEN_TONE[tok.kind]}`}
>
{tok.text}
</span>
)
);
}
/** Compact pill for /command invocations parsed out of TUI markup: the
* command in the prompt box's colour, its arguments after it. */
function CommandPill({ display }: { display: string }) {
const [name, ...args] = display.split(" ");
return (
<div className="inline-flex items-center gap-2 text-sm text-status-success font-mono bg-status-success/10 border border-status-success/20 rounded-md px-3 py-1.5 max-w-full">
<span className="text-status-success/70">›</span>
<span className="break-all">{display}</span>
<div className="inline-flex max-w-full items-baseline gap-2 rounded-lg border border-accent/25 bg-accent/10 px-2.5 py-1 font-mono text-sm">
<span className="font-semibold text-accent">{name}</span>
{args.length > 0 && (
<span className="break-all text-fg-secondary">{typedText(args.join(" "))}</span>
)}
</div>
);
}
@@ -792,7 +814,7 @@ const UserBubble = memo(function UserBubble({ msg }: { msg: TranscriptMessage })
const plain = !hasTuiTags(text) && !isSkillContent(text) && !isTaskNotification(text);
return plain ? (
<p key={b} className="whitespace-pre-wrap break-words leading-relaxed">
{linkify(stripAnsi(text).trim())}
{typedText(stripAnsi(text).trim())}
</p>
) : (
renderTextBlock(text, b)
@@ -66,6 +66,43 @@ describe("<MarkdownContent />", () => {
expect(screen.getByText("just a normal sentence.")).toBeInTheDocument();
});
it("highlights standalone numbers, never ones inside a word, sha or issue ref", () => {
const { container } = render(
<MarkdownContent
text={"Took 3.5s, 87% done, $1.20, v2.7.1 on 2026-10-12 — abc123 file.2 #67 2fd0c903 x9"}
/>
);
const nums = [...container.querySelectorAll("span.tabular-nums")].map((n) => n.textContent);
expect(nums).toEqual(["3.5s", "87%", "$1.20", "v2.7.1", "2026-10-12"]);
});
it("highlights an ISO date-time as one number", () => {
const { container } = render(
<MarkdownContent
text={"At 2026-10-12T08:00, 2026-10-12T08:00:00Z and 2026-10-12T08:00:00.123+07:00."}
/>
);
const nums = [...container.querySelectorAll("span.tabular-nums")].map((n) => n.textContent);
expect(nums).toEqual([
"2026-10-12T08:00",
"2026-10-12T08:00:00Z",
"2026-10-12T08:00:00.123+07:00",
]);
});
it("does not highlight numbers inside inline code", () => {
const { container } = render(<MarkdownContent text={"Run `sleep 5` now"} />);
expect(container.querySelector("span.tabular-nums")).toBeNull();
});
it("colours a flag label by how serious it is", () => {
render(<MarkdownContent text={"Lưu ý: a. Warning: b. Lỗi: c. Note that x"} />);
expect(screen.getByText("Lưu ý:").className).toContain("text-accent");
expect(screen.getByText("Warning:").className).toContain("text-status-warning");
expect(screen.getByText("Lỗi:").className).toContain("text-status-danger");
expect(screen.queryByText("Note")).toBeNull();
});
it("handles empty input safely", () => {
const { container } = render(<MarkdownContent text="" />);
// Wrapper exists but no block elements
@@ -4,11 +4,12 @@
* sender — the human as a bubble, the assistant's prose bare (Claude Desktop's
* layout), Main agent / System under their own label — instead of treating
* every `type:"user"` line as the user (reported transcript mis-attribution) —
* and folds a run of tool calls into one line.
* and folds a run of tool calls into one line; a typed `/command` or `@path`
* shows as a chip.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { MessageList, workLabel, workingOn } from "../MessageList";
import type { TranscriptMessage } from "../../../lib/types";
@@ -22,6 +23,21 @@ function msg(partial: Partial<TranscriptMessage>): TranscriptMessage {
}
describe("MessageList — sender attribution", () => {
it("shows a typed /command and @path as chips in the user's bubble", () => {
render(
<MessageList
messages={[
msg({ sender: "user", content: [{ type: "text", text: "/review @src/a.ts now" }] }),
]}
loading={false}
/>
);
const bubble = screen.getByTestId("chat-user");
expect(bubble).toHaveTextContent("/review @src/a.ts now");
expect(within(bubble).getByText("/review").className).toContain("text-accent");
expect(within(bubble).getByText("@src/a.ts").className).toContain("text-violet");
});
it("labels each row by its sender, not blanket 'User'", () => {
const messages: TranscriptMessage[] = [
msg({ sender: "user", content: [{ type: "text", text: "spin up a team" }] }),
+38 -33
View File
@@ -21,7 +21,7 @@
* commit (Annotate); a click hands its sha to `onBlameClick`. `navRef` gets
* a function that moves the cursor to the next / previous changed block.
* Given `onAddToChat`, a selection in the editable file gets Cursor's Add to
* Chat chip at the end of its first line (inline, so it covers no code),
* Chat chip floating above its last line, at the column where it ends,
* which hands the panel the selection's 1-based lines.
* Lazy-loaded by LaneChangesPanel, which keys it by file and shape:
* CodeMirror stays out of the main bundle, and one mount is one view.
@@ -50,6 +50,8 @@ import {
highlightActiveLineGutter,
keymap,
lineNumbers,
showTooltip,
type Tooltip,
type DecorationSet,
type ViewUpdate,
} from "@codemirror/view";
@@ -284,41 +286,34 @@ function blockBars(orig: Text, build: (b: EditorBlock) => HTMLElement) {
});
}
class ChatChip extends WidgetType {
constructor(
readonly start: number,
readonly end: number,
readonly build: (start: number, end: number) => HTMLElement
) {
super();
}
eq(o: ChatChip) {
return this.start === o.start && this.end === o.end;
}
toDOM() {
return this.build(this.start, this.end);
}
}
/** Add to Chat inline at the end of a non-empty selection's first line —
* the empty space right of the code, so it covers no line. `build` makes its
* DOM from the selection's first and last 1-based line. A selection ending
* at a line's start (a whole-line drag) does not take that line. */
/** Add to Chat floating just above the selection's last line, at the column
* where the selection ends — so a long line never pushes it out of view.
* `build` makes its DOM from the selection's first and last 1-based line. A
* selection ending at a line's start (a whole-line drag) does not take that
* line, and the chip then sits at the start of the last line it does take. */
function chatChip(build: (start: number, end: number) => HTMLElement) {
return StateField.define<DecorationSet>({
create: () => Decoration.none,
update(deco, tr) {
if (!tr.docChanged && !tr.selection) return deco;
return StateField.define<Tooltip | null>({
create: () => null,
update(tip, tr) {
if (!tr.docChanged && !tr.selection) return tip;
const { from, to, empty } = tr.state.selection.main;
if (empty) return Decoration.none;
if (empty) return null;
const doc = tr.state.doc;
const first = doc.lineAt(from);
const last = doc.lineAt(to);
const end = to === last.from && last.number > first.number ? last.number - 1 : last.number;
const widget = new ChatChip(first.number, end, build);
return Decoration.set(Decoration.widget({ widget, side: 1 }).range(first.to));
const wholeLine = to === last.from && last.number > first.number;
const end = wholeLine ? last.number - 1 : last.number;
return {
pos: wholeLine ? doc.line(end).from : to,
above: true,
create: () => {
const dom = build(first.number, end);
dom.classList.add("cm-chat-chip");
return { dom };
},
};
},
provide: (f) => EditorView.decorations.from(f),
provide: (f) => showTooltip.from(f),
});
}
@@ -355,9 +350,19 @@ const theme = EditorView.theme({
".cm-activeLine": { backgroundColor: "rgb(var(--fg-primary) / 0.04)" },
".cm-activeLineGutter": { backgroundColor: "rgb(var(--fg-primary) / 0.08)" },
".cm-cursor": { borderLeftColor: "rgb(var(--fg-primary))" },
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": {
backgroundColor: "rgb(var(--accent) / 0.3)",
// Doubled classes: drawSelection's base theme scopes its light/dark colours
// as `.ͼ1.ͼ2.cm-focused > .cm-scroller > .cm-selectionLayer …`, which a
// plain `.cm-selectionBackground` never outranks.
"&.cm-focused.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionLayer .cm-selectionBackground.cm-selectionBackground, .cm-content ::selection":
{
background: "rgb(var(--accent) / 0.3)",
},
// Doubled class: outranks the base theme's light/dark-scoped tooltip frame.
".cm-tooltip.cm-chat-chip.cm-chat-chip": {
border: "1px solid rgb(var(--accent) / 0.6)",
backgroundColor: "rgb(var(--surface-1))",
},
".cm-tooltip.cm-chat-chip.cm-chat-chip:hover": { backgroundColor: "rgb(var(--surface-2))" },
[`${M} .cm-changedLine`]: { backgroundColor: GREEN },
[`${M} .cm-changedLine.cm-ah-mod`]: { backgroundColor: BLUE },
[`${M} .cm-changedText`]: { background: "none" },
@@ -544,7 +549,7 @@ export default function DiffEditor({
b.dataset.testid = "lane-editor-add-to-chat";
b.textContent = cb.current.t("changes.addToChat");
b.className =
"ml-3 rounded border border-accent/60 bg-surface-1 px-1.5 font-sans text-[10px] leading-[14px] text-accent align-middle hover:bg-surface-2";
"rounded border border-accent/60 bg-surface-1 px-1.5 font-sans text-[10px] leading-[14px] text-accent hover:bg-surface-2";
// Keep the editor's focus (and so its selection) through the click.
b.onmousedown = (e) => e.preventDefault();
b.onclick = () => cb.current.onAddToChat?.(start, end);
@@ -313,10 +313,10 @@ describe("DiffEditor", () => {
expect(b).not.toBeNull();
return b!;
});
// Inline at the end of the first selected line, never a tooltip over the
// line above it.
expect(chip.closest(".cm-tooltip")).toBeNull();
expect(chip.closest(".cm-line")?.textContent).toMatch(/^b/);
// A tooltip above the last selected line, never inline at a line's end
// (a long line pushed it out of view).
expect(chip.classList.contains("cm-tooltip")).toBe(true);
expect(chip.closest(".cm-line")).toBeNull();
fireEvent.click(chip);
expect(onAddToChat).toHaveBeenCalledWith(2, 3);
view.dispatch({ selection: { anchor: 6 } });
+3 -1
View File
@@ -18,6 +18,7 @@
import { useEffect, useState, type ComponentType, type DragEvent, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { useSettingsHref } from "../../lib/settingsLink";
import {
BookOpen,
Check,
@@ -209,6 +210,7 @@ export function ChatOutline({
onOpenList: (list: OutlineList) => void;
}) {
const { t } = useTranslation("run");
const href = useSettingsHref();
const used = o.uploads.length + o.files.length + o.memory.length + o.skills.length + o.mcp.length;
const close = (
<button
@@ -329,7 +331,7 @@ export function ChatOutline({
icon={Plug}
label={t("chat.outline.connectors")}
names={o.mcp}
to="/settings/agents/claude-code?tab=mcp"
to={href("agents/claude-code?tab=mcp")}
/>
)}
</Section>
+67 -8
View File
@@ -28,7 +28,9 @@
* one, up to four panes). A right-click on a tab
* opens the same actions as a menu (`ContextMenu`). With tabs from every
* workspace on, `tabs` spans every workspace (each run's `slot` a `paneKey`)
* and `workspaceOf` names each tab's workspace before its own name.
* and `workspaceOf` names each tab's workspace before its own name. Settings
* opened on a workspace is one more tab at the bar's end (`settings`, kept
* until its X), shown instead of the panes while it is the active one.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -43,8 +45,9 @@ import {
} from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { House, Plus, Settings2, SquareTerminal, X } from "lucide-react";
import { House, Plus, Settings, Settings2, SquareTerminal, X } from "lucide-react";
import { api } from "../../lib/api";
import { useSettingsHref } from "../../lib/settingsLink";
import type { RunHandle } from "../../lib/api";
import { isSessionAwaitingInput, type Lane, type Session } from "../../lib/types";
import { toast } from "../../lib/toast";
@@ -152,6 +155,9 @@ interface Tab {
const URGENCY: RunState[] = ["awaiting", "error", "running"];
/** The Settings tab's key among the tabs' (a group's key is its slots). */
const SETTINGS_KEY = "settings";
export function ConsoleTabs({
lane,
tabs: runs,
@@ -164,6 +170,7 @@ export function ConsoleTabs({
sessions,
onRenamed,
workspaceOf,
settings,
}: {
lane: Lane;
/** The workspace's live runs, in slot order. */
@@ -187,9 +194,12 @@ export function ConsoleTabs({
onHome?: () => void;
/** Right end of the bar (the single pane's split menus). */
actions?: ReactNode;
/** The Settings tab, while it is open: on screen (`active`), shown, closed. */
settings?: { active: boolean; onOpen: () => void; onClose: () => void } | null;
}) {
const { t } = useTranslation("lanes");
const navigate = useNavigate();
const settingsHref = useSettingsHref();
const aiTools = useAiTools();
const [menuOpen, setMenuOpen] = useState(false);
// The tab being renamed (its run id) and the draft name.
@@ -397,8 +407,9 @@ export function ConsoleTabs({
};
// APG tabs: one tab stop; arrows, Home and End move between the tabs.
const keys = [...tabs.map((x) => x.key), ...(settings ? [SETTINGS_KEY] : [])];
const onTabKey = (e: ReactKeyboardEvent, i: number) => {
const last = tabs.length - 1;
const last = keys.length - 1;
const to =
e.key === "ArrowRight"
? i === last
@@ -413,11 +424,14 @@ export function ConsoleTabs({
: e.key === "End"
? last
: null;
if (to === null || !tabs[to]) return;
const key = to === null ? undefined : keys[to];
if (key === undefined) return;
e.preventDefault();
tabRefs.current.get(tabs[to].key)?.focus();
tabRefs.current.get(key)?.focus();
};
const tabStop = tabs.find((x) => x.index === groups.active)?.key ?? tabs[0]?.key;
const tabStop = settings?.active
? SETTINGS_KEY
: (tabs.find((x) => x.index === groups.active)?.key ?? keys[0]);
// A tab-menu item: close the menu, then act on the tab it was opened on.
const pickTab = (fn: (tab: Tab) => unknown) => () => {
@@ -456,7 +470,7 @@ export function ConsoleTabs({
className="flex min-w-0 items-stretch overflow-x-auto no-scrollbar"
>
{tabs.map((tab, i) => {
const active = tab.index === groups.active;
const active = !settings?.active && tab.index === groups.active;
const single = tab.runs.length === 1 ? tab.focus : null;
const state = tabState(tab);
if (single && renaming === single.id) {
@@ -569,6 +583,48 @@ export function ConsoleTabs({
</div>
);
})}
{settings && (
<div
className={`group relative flex shrink-0 items-center rounded-t-md ${
settings.active
? "bg-surface-0 text-fg-primary"
: "text-fg-muted hover:bg-surface-4 hover:text-fg-secondary"
}`}
>
{settings.active && (
<span className="absolute inset-x-2 -bottom-px h-0.5 rounded-full bg-accent" />
)}
<button
type="button"
role="tab"
aria-selected={settings.active}
tabIndex={tabStop === SETTINGS_KEY ? 0 : -1}
ref={(el) => {
if (el) tabRefs.current.set(SETTINGS_KEY, el);
else tabRefs.current.delete(SETTINGS_KEY);
}}
onKeyDown={(e) => onTabKey(e, keys.length - 1)}
data-testid="console-tab-settings"
onClick={settings.onOpen}
className="flex h-full items-center gap-2 pl-3 pr-1.5 text-sm"
>
<Settings className="h-4 w-4 shrink-0" />
{t("tabs.settings")}
</button>
<button
type="button"
data-testid="console-tab-settings-close"
onClick={settings.onClose}
aria-label={t("tabs.closeSettings")}
title={t("tabs.closeSettings")}
className={`mr-2 shrink-0 rounded p-0.5 hover:bg-surface-4 hover:text-fg-primary ${
settings.active ? "" : "opacity-0 group-hover:opacity-100 focus:opacity-100"
}`}
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
<div ref={menuRef} className="relative flex items-center px-1.5">
<button
@@ -618,7 +674,10 @@ export function ConsoleTabs({
type="button"
role="menuitem"
className={`${item} !text-fg-muted`}
onClick={() => navigate("/settings/agents")}
onClick={() => {
setMenuOpen(false);
navigate(settingsHref("agents"));
}}
>
<Settings2 className="h-4 w-4" />
{t("tabs.agentSettings")}
+3 -2
View File
@@ -28,8 +28,9 @@
* `SkillListModal`). Anything there drags into the prompt box. A file in a turn's
* "Edited N files" card opens in the Workspace's Changes panel
* (`openInChanges`) when it lies in the pane's folder. The unsent prompt and
* its attachments are kept per session (`lib/chatDraft.ts`), so switching to
* the terminal and back, or a reload, leaves them as typed. A send scrolls the
* its attachments are kept per session (`lib/chatDraft.ts`), so a reload
* leaves them as typed; switching to the terminal pastes them into its input
* box and back brings that box's text here (`ChatToggle`). A send scrolls the
* conversation to its end and keeps it there (`follow`), so the message and
* the step running after it come into sight on their own. While the session's
* main agent is working (a prompt submitted, no Stop yet) an empty box shows
+22 -2
View File
@@ -58,6 +58,9 @@ import {
import { AiToolIcon } from "../AiToolIcon";
import { RunDot, type RunState } from "./ConsoleTabs";
import { setSessionChat, useSessionChat } from "../../lib/chatUi";
import { readChatDraft, writeChatDraft } from "../../lib/chatDraft";
import { pasteToRun, takeRunInput } from "../../lib/panePaste";
import { promptWithImages } from "./RunSetup";
export const PANE_DRAG_TYPE = "application/x-agenthub-pane";
/** The slot being dragged: `dataTransfer` data cannot be read during dragover. */
@@ -195,7 +198,9 @@ const ITEM =
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-4 hover:text-fg-primary";
/** Chat UI ⇄ terminal for a Claude/Codex pane — this session's own view
* (`lib/chatUi.ts`); a session with no id yet has no chat to show. */
* (`lib/chatUi.ts`); a session with no id yet has no chat to show. The
* unsent prompt goes along: the terminal's input box into the chat's draft,
* the draft (its files as paths) back into the input box, never sent. */
function ChatToggle({ run }: { run?: RunHandle }) {
const { t } = useTranslation("lanes");
const chat = useSessionChat(run?.sessionId);
@@ -209,7 +214,22 @@ function ChatToggle({ run }: { run?: RunHandle }) {
aria-pressed={chat}
title={label}
aria-label={label}
onClick={() => setSessionChat(sessionId, !chat)}
onClick={() => {
const draft = readChatDraft(sessionId);
if (chat) {
const text = promptWithImages(draft.prompt, draft.images);
if (text && pasteToRun(run.id, text)) writeChatDraft(sessionId, "", []);
} else {
const text = takeRunInput(run.id);
if (text)
writeChatDraft(
sessionId,
[draft.prompt, text].filter(Boolean).join("\n"),
draft.images
);
}
setSessionChat(sessionId, !chat);
}}
className="flex h-6 w-6 items-center justify-center rounded text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
>
{chat ? (
+73 -32
View File
@@ -12,6 +12,8 @@
* remembered for the + menu's Recent uploads), and a panel row (Files,
* Changes, a context file or folder) dropped on it (`lib/promptAttach.ts`)
* as an `@path` chip — a `bare` one takes a drop anywhere on its box;
* its `/command` and `@path` tokens are coloured by a mirror laid over
* the textarea (`lib/promptTokens.ts`);
* a borderless (`bare`) one grows with its text up to 40vh;
* - `CwdAutocomplete` — the folder field the Add-workspace modal uses;
* - `ModelPicker` — a tool's own model list (`GET /api/ai-tools/:id/models`)
@@ -61,6 +63,7 @@ import { Select, menuRoom } from "../Select";
import { ContextMenu, MenuItem, menuPoint, type MenuPoint } from "../ContextMenu";
import { isSubmitEnter } from "../../lib/keys";
import { ImageLightbox } from "../ImageLightbox";
import { TOKEN_TONE, promptTokens } from "../../lib/promptTokens";
import {
ATTACH_MIME,
addAttachments,
@@ -224,6 +227,15 @@ export function PromptEditor({
}: PromptEditorProps) {
const { t } = useTranslation("run");
const taRef = useRef<HTMLTextAreaElement | null>(null);
// With a `/command` or `@path` in it, the textarea's own text turns
// transparent and a mirror over it — same box, font and wrapping, scrolled
// with it — draws the text with those tokens coloured.
const mirrorRef = useRef<HTMLDivElement | null>(null);
const tokens = useMemo(() => promptTokens(value), [value]);
const marked = tokens.some((tok) => tok.kind !== "text");
const box = bare
? "block max-h-[40vh] w-full overflow-y-auto px-1 py-1 text-sm"
: "w-full rounded-md border px-3 py-2 text-[11px] font-mono";
// A composer grows with its text: reset to its rows, then take what the
// text needs; `max-h-[40vh]` caps it and it scrolls from there.
useLayoutEffect(() => {
@@ -232,6 +244,10 @@ export function PromptEditor({
ta.style.height = "auto";
ta.style.height = `${ta.scrollHeight}px`;
}, [bare, value]);
// A mirror that just appeared starts at the top, whatever the textarea shows.
useLayoutEffect(() => {
if (mirrorRef.current && taRef.current) mirrorRef.current.scrollTop = taRef.current.scrollTop;
}, [marked]);
const [trigger, setTrigger] = useState<Trigger | null>(null);
const [active, setActive] = useState(0);
const [files, setFiles] = useState<string[]>([]);
@@ -545,39 +561,64 @@ export function PromptEditor({
}}
/>
)}
<textarea
ref={taRef}
value={value}
onChange={(e) => {
onChange(e.target.value);
retrack(e.target);
setActive(0);
}}
onSelect={(e) => retrack(e.currentTarget)}
onBlur={() => setTrigger(null)}
onKeyDown={handleKeyDown}
onPaste={(e) => {
if (attachImages(e.clipboardData?.files)) e.preventDefault();
else {
// A very long paste goes in as a text file, like Claude Desktop:
// the prompt then carries its path, never the text (#68: a CLI's
// argument and a tmux message both have a size limit).
const text = e.clipboardData?.getData("text/plain") ?? "";
if (text.length > PASTE_AS_FILE_CHARS) {
e.preventDefault();
attachImages([new File([text], "pasted-text.txt", { type: "text/plain" })]);
<div className="relative">
<textarea
ref={taRef}
onScroll={(e) => {
if (mirrorRef.current) mirrorRef.current.scrollTop = e.currentTarget.scrollTop;
}}
value={value}
onChange={(e) => {
onChange(e.target.value);
retrack(e.target);
setActive(0);
}}
onSelect={(e) => retrack(e.currentTarget)}
onBlur={() => setTrigger(null)}
onKeyDown={handleKeyDown}
onPaste={(e) => {
if (attachImages(e.clipboardData?.files)) e.preventDefault();
else {
// A very long paste goes in as a text file, like Claude Desktop:
// the prompt then carries its path, never the text (#68: a CLI's
// argument and a tmux message both have a size limit).
const text = e.clipboardData?.getData("text/plain") ?? "";
if (text.length > PASTE_AS_FILE_CHARS) {
e.preventDefault();
attachImages([new File([text], "pasted-text.txt", { type: "text/plain" })]);
}
}
}
}}
placeholder={placeholder}
rows={rows}
spellCheck={false}
className={
bare
? "block max-h-[40vh] w-full resize-none overflow-y-auto bg-transparent px-1 py-1 text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none"
: "w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
}
/>
}}
placeholder={placeholder}
rows={rows}
spellCheck={false}
className={`${box} resize-none placeholder:text-fg-muted focus:outline-none ${
marked ? "text-transparent caret-fg-primary" : "text-fg-primary"
} ${bare ? "bg-transparent" : "bg-surface-2 border-border focus:border-accent/50"}`}
/>
{marked && (
<div
ref={mirrorRef}
aria-hidden
data-testid="prompt-mirror"
// Its own scrollbar keeps the width the textarea's takes, but is
// invisible, so the real one shows through.
className={`${box} pointer-events-none absolute inset-0 overflow-y-auto whitespace-pre-wrap break-words border-transparent text-fg-primary [scrollbar-color:transparent_transparent]`}
>
{tokens.map((tok, i) =>
tok.kind === "text" ? (
tok.text
) : (
<span key={i} className={`rounded-sm ${TOKEN_TONE[tok.kind]}`}>
{tok.text}
</span>
)
)}
{/* A trailing newline is a line in a textarea, not in a div. */}
{"\u200b"}
</div>
)}
</div>
{upload && (
<p
role={upload === "failed" ? "alert" : "status"}
@@ -562,6 +562,24 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
() => !!containerRef.current?.offsetWidth && wsRef.current?.readyState === WebSocket.OPEN,
(data) => {
if (wsRef.current?.readyState === WebSocket.OPEN) wsRef.current.send(data);
},
// The rows on screen, dim cells (a TUI's placeholder) left out.
() => {
const term = termRef.current;
if (!term) return null;
const buf = term.buffer.active;
const rows: string[] = [];
for (let y = 0; y < term.rows; y++) {
const line = buf.getLine(buf.baseY + y);
let row = "";
for (let x = 0; line && x < line.length; x++) {
const cell = line.getCell(x);
// Width 0: the right half of a wide character.
if (cell && cell.getWidth() && !cell.isDim()) row += cell.getChars() || " ";
}
rows.push(row);
}
return { rows, cols: term.cols };
}
),
[runId]
@@ -5,7 +5,7 @@
* open, `/` only where slash commands apply), Shift/Alt+Enter newlines, a
* pasted image shown as a thumbnail and sent as its saved path (and
* remembered as a recent upload), a Files panel row dropped as an `@path`
* chip, a composer
* chip, `/command` and `@path` coloured by a mirror over the text, a composer
* (`bare`) box growing with its text up to a cap, and `ModelPicker` — a tool's own
* list from the server, a fixed list (an AI Proxy's models) when given one,
* and Custom…, which is a sentinel, never a model id — and `ModelMenu`, the
@@ -16,7 +16,7 @@
*/
import { useState } from "react";
import { createEvent, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { createEvent, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
import {
@@ -117,6 +117,31 @@ function renderEditor(value: string, slash = true) {
const box = () => screen.getByPlaceholderText("Ask anything…");
describe("PromptEditor — / and @ suggestions", () => {
it("colours /command and @path tokens through a mirror, only when there are any", () => {
const { unmount } = render(
<PromptEditor
value={"/review @src/a.ts please"}
placeholder="Ask anything…"
slash
fileCwd="/Users/tester"
images={[]}
onImagesChange={() => {}}
onChange={() => {}}
onSubmit={() => {}}
/>
);
const mirror = screen.getByTestId("prompt-mirror");
expect(mirror).toHaveAttribute("aria-hidden");
expect(within(mirror).getByText("/review").className).toContain("text-accent");
expect(within(mirror).getByText("@src/a.ts").className).toContain("text-violet");
expect(box().className).toContain("text-transparent");
unmount();
renderEditor("plain words");
expect(screen.queryByTestId("prompt-mirror")).toBeNull();
expect(box().className).toContain("text-fg-primary");
});
it("lists the folder's commands, skills and the CLI built-ins on /, and Enter picks one", async () => {
const spies = renderEditor("");
fireEvent.change(box(), { target: { value: "/rev" } });
@@ -0,0 +1,171 @@
/**
* @file AgentInstructionsSettings — Settings → AI tools → My agent
* instructions (issue #67): the account's own rules in markdown, put before
* every workspace's own instructions in each new Claude or Codex session of a
* workspace it owns (`GET/PUT /api/me/instructions`). Saved per account. A
* failed load says so with a Retry instead of leaving a dead, empty editor —
* the editor stays read-only until the saved text is known, so a save can
* never overwrite instructions it did not load. **Choose file** reads a local
* Markdown/text file in the browser into the draft (nothing is uploaded until
* Save, and Discard brings the saved text back). "Applies to" lists only the
* installed, enabled tools whose panes the server says receive them.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef, useState, type ChangeEvent } from "react";
import { FileUp } from "lucide-react";
import { useTranslation } from "react-i18next";
import { api, type AgentInstructionsView } from "../../lib/api";
import { useAiTools } from "../../hooks/useAiTools";
import { AiToolIcon } from "../AiToolIcon";
import { toast } from "../../lib/toast";
import { SettingsSection } from "./SettingsRow";
export function AgentInstructionsSettings() {
const { t } = useTranslation("settings");
const [saved, setSaved] = useState<string | null>(null);
const [draft, setDraft] = useState("");
const [max, setMax] = useState<number | undefined>();
const [toolIds, setToolIds] = useState<string[]>([]);
const { tools } = useAiTools();
const [loadError, setLoadError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const fileInput = useRef<HTMLInputElement | null>(null);
const apply = (r: AgentInstructionsView) => {
setSaved(r.instructions);
setDraft(r.instructions);
setMax(r.max);
setToolIds(r.tools ?? []);
};
const load = () => {
setLoadError(null);
api.me
.instructions()
.then(apply)
.catch((err) => setLoadError(err instanceof Error ? err.message : String(err)));
};
useEffect(load, []);
async function save() {
setSaving(true);
try {
apply(await api.me.saveInstructions(draft));
toast.success(t("agentInstructions.saved"));
} catch (err) {
toast.error(
t("agentInstructions.saveFailed", { message: err instanceof Error ? err.message : "" })
);
} finally {
setSaving(false);
}
}
async function pickFile(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = ""; // the same file can be picked again after a Discard
if (!file) return;
const text = await file.text();
if (max && text.length > max) {
toast.error(
t("agentInstructions.fileTooBig", {
name: file.name,
size: text.length.toLocaleString(),
max: max.toLocaleString(),
})
);
return;
}
setDraft(text);
}
const appliesTo = tools.filter((x) => x.installed && x.enabled && toolIds.includes(x.id));
const ready = saved !== null;
const dirty = ready && draft !== saved;
return (
<SettingsSection
id="agent-instructions"
title={t("agentInstructions.title")}
description={t("agentInstructions.description")}
>
<div className="pt-1">
{appliesTo.length > 0 && (
<div className="mb-3 flex flex-wrap items-center gap-1.5 text-xs text-fg-muted">
<span>{t("agentInstructions.appliesTo")}</span>
{appliesTo.map((x) => (
<span
key={x.id}
className="inline-flex items-center gap-1.5 rounded-full border border-border bg-surface-2 py-0.5 pl-1 pr-2.5 text-fg-secondary"
>
<AiToolIcon tool={x} className="h-4 w-4" />
{x.label}
</span>
))}
</div>
)}
<textarea
aria-label={t("agentInstructions.title")}
className="block w-full min-h-[180px] resize-y rounded-lg border border-border bg-surface-1 px-3 py-2.5 font-mono text-xs leading-relaxed text-fg-primary placeholder:text-fg-muted/70 focus:border-accent focus:outline-none disabled:opacity-60"
rows={8}
spellCheck={false}
placeholder={t("agentInstructions.placeholder")}
value={draft}
maxLength={max}
disabled={!ready}
onChange={(e) => setDraft(e.target.value)}
/>
<div className="mt-3 flex items-center justify-between gap-3">
{loadError ? (
<p className="flex min-w-0 items-center gap-2 text-xs text-status-danger">
<span className="truncate">
{t("agentInstructions.loadFailed", { message: loadError })}
</span>
<button type="button" className="btn-ghost shrink-0 text-xs" onClick={load}>
{t("common:retry")}
</button>
</p>
) : (
<span className="text-[11px] tabular-nums text-fg-muted">
{ready && max ? `${draft.length.toLocaleString()} / ${max.toLocaleString()}` : ""}
</span>
)}
<div className="flex shrink-0 items-center gap-2">
<input
ref={fileInput}
type="file"
accept=".md,.markdown,.txt,text/markdown,text/plain"
className="hidden"
data-testid="agent-instructions-file"
onChange={(e) => void pickFile(e)}
/>
<button
type="button"
className="btn-ghost inline-flex items-center gap-1.5 text-xs disabled:opacity-50"
disabled={!ready || saving}
onClick={() => fileInput.current?.click()}
>
<FileUp className="h-3.5 w-3.5" />
{t("agentInstructions.chooseFile")}
</button>
{dirty && !saving && (
<button type="button" className="btn-ghost text-xs" onClick={() => setDraft(saved)}>
{t("agentInstructions.discard")}
</button>
)}
<button
type="button"
className="btn-primary text-xs disabled:cursor-not-allowed disabled:opacity-50"
disabled={saving || !dirty}
onClick={() => void save()}
>
{saving ? t("homeDir.saving") : t("homeDir.save")}
</button>
</div>
</div>
</div>
</SettingsSection>
);
}
@@ -20,6 +20,7 @@ import { Checkbox } from "../Checkbox";
import { useEffect, useState, useCallback, useRef, Fragment } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { useSettingsHref } from "../../lib/settingsLink";
import {
ArrowRight,
DollarSign,
@@ -208,6 +209,7 @@ export type PricingVendor = "claude" | "openai" | "google";
*/
export function PricingLink({ vendor }: { vendor: PricingVendor }) {
const { t } = useTranslation("settings");
const href = useSettingsHref();
return (
<div className="card flex items-center justify-between gap-4 p-5">
<div className="min-w-0">
@@ -217,7 +219,7 @@ export function PricingLink({ vendor }: { vendor: PricingVendor }) {
</h3>
<p className="mt-0.5 text-xs text-fg-muted">{t("pricingLink.description")}</p>
</div>
<Link to={`/settings/pricing#pricing-${vendor}`} className="btn-ghost shrink-0 text-xs">
<Link to={href(`pricing#pricing-${vendor}`)} className="btn-ghost shrink-0 text-xs">
{t("pricingLink.open")}
<ArrowRight className="h-3.5 w-3.5" />
</Link>
@@ -0,0 +1,96 @@
/**
* @file AgentInstructionsSettings.test.tsx
* @description My agent instructions: a failed load shows the error with a
* Retry (the editor stays locked, so a save cannot overwrite unloaded text)
* and Retry unlocks it; Choose file puts a local file's text into the draft
* without saving, and refuses one longer than the limit; "Applies to" names
* only the installed, enabled tools the server says receive them.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { AgentInstructionsSettings } from "../AgentInstructionsSettings";
import { api } from "../../../lib/api";
import { toast } from "../../../lib/toast";
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: { me: { instructions: vi.fn(), saveInstructions: vi.fn() } },
};
});
const aiTools = vi.hoisted(() => ({
tools: [] as { id: string; label: string; installed: boolean; enabled: boolean }[],
}));
vi.mock("../../../hooks/useAiTools", () => ({ useAiTools: () => aiTools }));
vi.mock("../../../lib/toast", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
const instructions = vi.mocked(api.me.instructions);
const saveInstructions = vi.mocked(api.me.saveInstructions);
const editor = () => screen.getByRole("textbox") as HTMLTextAreaElement;
// jsdom's File has no text(); every browser AgentHub supports does.
const pick = (text: string, name = "rules.md") => {
const file = Object.assign(new File([text], name, { type: "text/markdown" }), {
text: () => Promise.resolve(text),
});
fireEvent.change(screen.getByTestId("agent-instructions-file"), { target: { files: [file] } });
};
describe("AgentInstructionsSettings", () => {
beforeEach(() => {
vi.clearAllMocks();
aiTools.tools = [];
});
it("names only the installed, enabled tools that receive the instructions", async () => {
aiTools.tools = [
{ id: "claude", label: "Claude Code", installed: true, enabled: true },
{ id: "codex", label: "Codex", installed: true, enabled: false },
{ id: "cursor", label: "Cursor CLI", installed: true, enabled: true },
];
instructions.mockResolvedValue({ instructions: "", max: 100, tools: ["claude", "codex"] });
render(<AgentInstructionsSettings />);
expect(await screen.findByText("Claude Code")).toBeInTheDocument();
expect(screen.getByText("Applies to")).toBeInTheDocument();
expect(screen.queryByText("Codex")).toBeNull();
expect(screen.queryByText("Cursor CLI")).toBeNull();
});
it("shows a failed load with Retry, and Retry unlocks the editor", async () => {
instructions.mockRejectedValueOnce(new Error("HTTP 404"));
render(<AgentInstructionsSettings />);
expect(await screen.findByText(/HTTP 404/)).toBeInTheDocument();
expect(editor()).toBeDisabled();
instructions.mockResolvedValueOnce({ instructions: "# saved", max: 100, tools: [] });
fireEvent.click(screen.getByRole("button", { name: /retry/i }));
await waitFor(() => expect(editor()).not.toBeDisabled());
expect(editor().value).toBe("# saved");
});
it("Choose file fills the draft without saving; Save sends it", async () => {
instructions.mockResolvedValue({ instructions: "", max: 100, tools: [] });
saveInstructions.mockResolvedValue({ instructions: "# from file", max: 100, tools: [] });
render(<AgentInstructionsSettings />);
await waitFor(() => expect(editor()).not.toBeDisabled());
pick("# from file");
await waitFor(() => expect(editor().value).toBe("# from file"));
expect(saveInstructions).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
await waitFor(() => expect(saveInstructions).toHaveBeenCalledWith("# from file"));
});
it("refuses a file longer than the limit and keeps the draft", async () => {
instructions.mockResolvedValue({ instructions: "keep", max: 5, tools: [] });
render(<AgentInstructionsSettings />);
await waitFor(() => expect(editor()).not.toBeDisabled());
pick("way too long");
await waitFor(() => expect(toast.error).toHaveBeenCalled());
expect(editor().value).toBe("keep");
});
});
+2
View File
@@ -384,6 +384,8 @@
"tabs.close": "Close tab (stops its session)",
"tabs.full": "All 10 sessions are in use — stop one first",
"tabs.agentSettings": "Agent settings…",
"tabs.settings": "Settings",
"tabs.closeSettings": "Close Settings",
"tabs.splitHint": "Ctrl/Cmd+click: open in a split · drag onto a pane to place it",
"panes.drag": "Drag to move this pane — onto another pane's edge to split there, onto its middle to swap",
"panes.pick": "Show another session in this pane",
+12
View File
@@ -1032,5 +1032,17 @@
"label": "Open new sessions as a chat",
"labelDesc": "A Claude or Codex session first shows its conversation like Claude Desktop, with a message box, instead of its terminal. Each session then keeps its own view: the chat/terminal button beside a pane's split buttons switches it. Permission prompts and menus still need the terminal.",
"saveFailed": "Could not save: {{message}}"
},
"agentInstructions": {
"title": "My agent instructions",
"description": "Your own rules in Markdown — coding style, language, workflow. Every new session in a workspace you own starts with them, before that workspace's own instructions. Saved for your account only; sessions already running keep what they started with.",
"placeholder": "# How I work\n- Answer in Vietnamese\n- Keep diffs small; run the tests before finishing",
"saved": "Instructions saved — new sessions will use them",
"saveFailed": "Could not save: {{message}}",
"loadFailed": "Could not load your instructions: {{message}}",
"discard": "Discard",
"chooseFile": "Choose file",
"fileTooBig": "{{name}} is {{size}} characters — instructions hold at most {{max}}",
"appliesTo": "Applies to"
}
}
+2
View File
@@ -384,6 +384,8 @@
"tabs.close": "Đóng tab (dừng phiên của nó)",
"tabs.full": "Đã dùng hết 10 session — hãy dừng bớt một session",
"tabs.agentSettings": "Cài đặt agent…",
"tabs.settings": "Cài đặt",
"tabs.closeSettings": "Đóng Cài đặt",
"tabs.splitHint": "Ctrl/Cmd+click: mở ở split · kéo thả vào một pane để đặt vị trí",
"panes.drag": "Kéo để di chuyển pane — thả vào cạnh pane khác để tách ở đó, vào giữa để đổi chỗ",
"panes.pick": "Hiện session khác trong pane này",
+12
View File
@@ -1032,5 +1032,17 @@
"label": "Mở session mới dạng chat",
"labelDesc": "Session Claude hoặc Codex sẽ mở hội thoại như Claude Desktop, kèm ô nhập tin, thay cho terminal. Sau đó mỗi session giữ dạng xem riêng: nút chat/terminal cạnh nút split của pane để chuyển. Hỏi quyền và menu vẫn cần terminal.",
"saveFailed": "Không lưu được: {{message}}"
},
"agentInstructions": {
"title": "Hướng dẫn agent của tôi",
"description": "Quy tắc riêng của bạn bằng Markdown — style code, ngôn ngữ, quy trình. Mọi session mới trong workspace của bạn đều nhận chúng, trước hướng dẫn riêng của workspace. Chỉ lưu cho tài khoản của bạn; session đang chạy giữ nội dung lúc khởi động.",
"placeholder": "# Cách tôi làm việc\n- Trả lời bằng tiếng Việt\n- Diff nhỏ; chạy test trước khi xong",
"saved": "Đã lưu — các session mới sẽ dùng nội dung này",
"saveFailed": "Không lưu được: {{message}}",
"loadFailed": "Không tải được hướng dẫn: {{message}}",
"discard": "Bỏ thay đổi",
"chooseFile": "Chọn file",
"fileTooBig": "{{name}} dài {{size}} ký tự — hướng dẫn tối đa {{max}}",
"appliesTo": "Áp dụng cho"
}
}
+59 -2
View File
@@ -4,12 +4,38 @@
* focused last, else any of its panes, never another workspace's (lane 1 is
* not lane 12), nowhere once the pane unmounted, and never into a pane that
* is not on screen (a hidden console behind a full-width Changes panel); and
* `interruptRun`, the Chat view's Stop, presses Esc only in a visible pane.
* `interruptRun`, the Chat view's Stop, presses Esc only in a visible pane;
* and the input box read off a real `claude`/`codex` screen (`parsePromptInput`)
* and moved out of it (`takeRunInput`) when the pane switches to Chat.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it } from "vitest";
import { interruptRun, notePaneFocus, pasteToLane, registerPane } from "../panePaste";
import {
interruptRun,
notePaneFocus,
parsePromptInput,
pasteToLane,
registerPane,
takeRunInput,
} from "../panePaste";
const RULE = "─".repeat(60);
// Claude Code at 60 columns: a wrapped first line, then a typed newline. Its
// box draws `❯` and a NO-BREAK space (U+00A0), never a plain one.
const CLAUDE = [
"❯ Use the Bash tool to run: sleep 20",
"",
RULE,
"❯\u00a0alpha beta gamma delta epsilon zeta eta theta iota kappa",
" lambda mu nu xi omicron pi rho sigma tau",
" Tiếng Việt có dấu",
RULE,
" Model: Haiku 5.5",
" ⏵⏵ auto mode on (shift+tab to cycle)",
];
// Codex at 60 columns (its dim placeholder already left out of an empty box).
const CODEX = ["› earlier message", "", "› alpha beta", " indented", "", " GPT-6 · /tmp", ""];
describe("panePaste", () => {
it("pastes into the workspace's last-focused pane, and only its own", () => {
@@ -65,4 +91,35 @@ describe("panePaste", () => {
expect(keys).toEqual(["\x1b"]);
off();
});
it("reads claude's and codex's input box, joining soft-wrapped rows back", () => {
expect(parsePromptInput(CLAUDE, 60)).toBe(
"alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho sigma tau\nTiếng Việt có dấu"
);
expect(parsePromptInput(CODEX, 60)).toBe("alpha beta\n indented");
expect(parsePromptInput([RULE, "❯\u00a0", RULE, " ⏵⏵ auto mode on"], 60)).toBe("");
expect(parsePromptInput(["› ", "", " GPT-6 · /tmp", " ← for agents"], 60)).toBe("");
// A menu or permission prompt in the box's place: nothing to read.
expect(parsePromptInput(["Do you want to proceed?", "❯ 1. Yes", " 2. No"], 60)).toBeNull();
});
it("takeRunInput deletes what it read, and leaves a paste placeholder alone", () => {
const keys: string[] = [];
let rows = CODEX;
const off = registerPane(
"agenthub-lane-7",
() => {},
() => true,
(d) => keys.push(d),
() => ({ rows, cols: 60 })
);
expect(takeRunInput("agenthub-lane-7")).toBe("alpha beta\n indented");
const n = "alpha beta\n indented".length + 1;
expect(keys).toEqual(["\x1b[3~".repeat(n) + "\x7f".repeat(n)]);
rows = [RULE, "❯ [Pasted text #1 +40 lines] and more", RULE];
expect(takeRunInput("agenthub-lane-7")).toBe("");
expect(keys).toHaveLength(1);
off();
expect(takeRunInput("agenthub-lane-7")).toBe("");
});
});
@@ -0,0 +1,43 @@
/**
* @file Tests for promptTokens: `/command` and `@path` at a word start become
* tokens; paths, e-mails, mid-word slashes and trailing punctuation do not.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it } from "vitest";
import { promptTokens } from "../promptTokens";
const kinds = (s: string) => promptTokens(s).map((t) => `${t.kind}:${t.text}`);
describe("promptTokens", () => {
it("splits commands and mentions out of the text", () => {
expect(kinds("/review @src/a.ts now")).toEqual([
"slash:/review",
"text: ",
"mention:@src/a.ts",
"text: now",
]);
expect(kinds("run /agenthub:file-headers\nok")).toEqual([
"text:run ",
"slash:/agenthub:file-headers",
"text:\nok",
]);
});
it("leaves paths, e-mails and mid-word slashes as text", () => {
expect(kinds("see /home/x and/or me@x.io")).toEqual(["text:see /home/x and/or me@x.io"]);
});
it("drops trailing prose punctuation from a mention", () => {
expect(kinds("read @a.ts, then")).toEqual(["text:read ", "mention:@a.ts", "text:, then"]);
});
it("keeps the text whole, so it can be laid over the input", () => {
const s = " /x @y.\n@z ";
expect(
promptTokens(s)
.map((t) => t.text)
.join("")
).toBe(s);
});
});
@@ -0,0 +1,35 @@
/**
* @file Tests for settingsHref: the Settings page off a workspace, the
* workspace's Settings tab on one (lane and run kept, a replay and a previous
* sub-tab dropped, the section's own query and anchor carried).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it } from "vitest";
import { settingsHref } from "../settingsLink";
describe("settingsHref", () => {
it("opens the Settings page off a workspace", () => {
const at = { pathname: "/sessions", search: "" };
expect(settingsHref("", at)).toBe("/settings");
expect(settingsHref("agents/claude-code?tab=mcp", at)).toBe(
"/settings/agents/claude-code?tab=mcp"
);
expect(settingsHref("appearance#pet", at)).toBe("/settings/appearance#pet");
// The Workspace page with no workspace yet has no tab bar to open it in.
expect(settingsHref("agents", { pathname: "/workspace", search: "" })).toBe("/settings/agents");
});
it("opens the workspace's Settings tab on a workspace", () => {
const at = { pathname: "/workspace", search: "?lane=3&run=agenthub-lane-3-1&tab=skills" };
expect(settingsHref("agents/claude-code?tab=mcp", at)).toBe(
"/workspace?lane=3&run=agenthub-lane-3-1&settings=agents%2Fclaude-code&tab=mcp"
);
expect(settingsHref("", { pathname: "/workspace", search: "?lane=3&session=abc" })).toBe(
"/workspace?lane=3&settings="
);
expect(
settingsHref("pricing#pricing-openai", { pathname: "/workspace", search: "?lane=3" })
).toBe("/workspace?lane=3&settings=pricing#pricing-openai");
});
});
+15
View File
@@ -692,6 +692,14 @@ export const api = {
/** PUT /api/me/prefs — merge; `null` deletes a key. */
savePrefs: (prefs: Record<string, string | null>) =>
request<UserPrefs>("/me/prefs", { method: "PUT", body: JSON.stringify({ prefs }) }),
/** GET /api/me/instructions — this account's own agent instructions (issue #67). */
instructions: () => request<AgentInstructionsView>("/me/instructions"),
/** PUT /api/me/instructions — blank clears; put before every workspace's own. */
saveInstructions: (instructions: string) =>
request<AgentInstructionsView>("/me/instructions", {
method: "PUT",
body: JSON.stringify({ instructions }),
}),
/** POST /api/me/onboarded — the first-run setup is done (or skipped). */
onboarded: () => request<UserPrefs>("/me/onboarded", { method: "POST" }),
},
@@ -3566,6 +3574,13 @@ export interface GitAuthNeeded {
}
/** One AI coding CLI from the server's AI Tools catalog (GET /api/ai-tools). */
/** GET/PUT /api/me/instructions — `tools`: the AI tool ids whose new panes receive them. */
export interface AgentInstructionsView {
instructions: string;
max: number;
tools: string[];
}
export interface AiTool {
id: string;
label: string;
+71 -4
View File
@@ -10,27 +10,35 @@
* unseen); the caller says where it went, or that it went nowhere. The one
* exception is `submitToRun`: a pane's own Chat view (`PaneChat.tsx`) sends
* what the user typed there, so it pastes AND presses Enter — and its Stop
* button presses Esc through `interruptRun`.
* button presses Esc through `interruptRun`. Switching a pane between Chat and
* terminal moves the unsent prompt across (`takeRunInput` reads the TUI's
* input box off the screen and empties it; the chat's draft is pasted back
* without Enter), so the half-typed text is never left behind on the other side.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/** The terminal's screen: one string per row, dim cells (a placeholder) left out. */
export type PaneScreen = { rows: string[]; cols: number };
interface Pane {
paste: (text: string, submit?: boolean) => void;
visible: () => boolean;
key?: (data: string) => void;
screen?: () => PaneScreen | null;
}
const panes = new Map<string, Pane>();
let lastFocused: string | null = null;
/** Registers a mounted terminal (`visible`: is it on screen now; `key`: send
* raw input, as a key press); returns its unregister. */
* raw input, as a key press; `screen`: its rows now); returns its unregister. */
export function registerPane(
runId: string,
paste: (text: string, submit?: boolean) => void,
visible: () => boolean = () => true,
key?: (data: string) => void
key?: (data: string) => void,
screen?: () => PaneScreen | null
): () => void {
const entry = { paste, visible, key };
const entry = { paste, visible, key, screen };
panes.set(runId, entry);
return () => {
if (panes.get(runId) === entry) panes.delete(runId);
@@ -84,3 +92,62 @@ export function pasteToLane(laneId: number, text: string): string | null {
panes.get(id)!.paste(text);
return id;
}
const BORDER = /^─{3,}$/;
/** The text in a `claude` or `codex` input box on `rows`, or null when no box
* is shown (a menu, a permission prompt). Claude's box is the `❯` row under a
* `─` rule, up to the next rule; Codex's the last `›` row, up to the blank row
* above its footer. Both indent a continued row by two and wrap at word
* boundaries, so a row whose first word would have fitted on the one above
* starts a new line — a soft-wrapped row is joined back with its space. */
export function parsePromptInput(rows: string[], cols: number): string | null {
const r = rows.map((l) => l.trimEnd());
let p = -1;
let end = -1;
for (let i = r.length - 1; i >= 0 && p < 0; i--) {
const row = r[i] ?? "";
// Claude draws `❯` + a no-break space (U+00A0) in its box.
if (/^❯([ \u00a0]|$)/.test(row) && BORDER.test(r[i - 1]?.trim() ?? "")) {
p = i;
end = r.findIndex((l, j) => j > i && BORDER.test(l.trim()));
} else if (/^›( |$)/.test(row)) {
p = i;
let f = r.length - 1;
while (f > i && !r[f]) f--;
while (f > i && r[f]) f--;
end = f > i ? f : -1;
}
}
if (p < 0 || end < 0) return null;
const lines = r.slice(p, end).map((l) => l.slice(2));
while (lines.length > 1 && !lines[lines.length - 1]) lines.pop();
const width = cols - 2;
let out = "";
let prev: string | null = null;
for (const line of lines) {
const word = line.split(" ")[0] ?? "";
// The first row; a word longer than the row, broken mid-word; one that
// would not fit after the row above, wrapped; else a typed newline.
if (prev !== null)
out += prev.length >= width ? "" : word && prev.length + 1 + word.length > width ? " " : "\n";
out += line;
prev = line;
}
return out;
}
/** Moves the text out of `runId`'s input box: returns it and deletes it there
* (Delete then Backspace, as many as it has characters — never Ctrl+C or
* Esc, which stop a working turn). "" when no box can be read, it is empty,
* or it holds what the screen cannot show back (`[Pasted text #1]`,
* `[Image #1]`) — then it is left as it is. */
export function takeRunInput(runId: string): string {
const p = panes.get(runId);
const screen = p?.visible() && p.key ? p.screen?.() : null;
const text = screen ? parsePromptInput(screen.rows, screen.cols) : null;
if (!text?.trim() || /\[(Pasted text|Image) #\d+/.test(text)) return "";
const n = text.length + 1;
p!.key!("\x1b[3~".repeat(n) + "\x7f".repeat(n));
return text;
}
+32
View File
@@ -0,0 +1,32 @@
/**
* @file promptTokens — splits a prompt into plain text, `/command` and
* `@path` tokens, so the prompt box and a sent message colour them the same
* way. A token starts a word (the line start or after whitespace), like the
* prompt box's autocomplete, so `a/b`, `me@x.io` and `/home/x` stay text.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
export type PromptToken = { kind: "text" | "slash" | "mention"; text: string };
/** A token's colours, shared by the prompt box and a sent message; `@path`
* takes the chat's path-link violet. */
export const TOKEN_TONE = {
slash: "bg-accent/15 text-accent",
mention: "bg-violet-500/15 text-violet-700 dark:text-violet-300",
} as const;
// `/name` (plugin skills are `plugin:name`) ending the word; `@path` minus a
// trailing full stop or comma, which is prose.
const TOKEN_RE = /(?<=^|\s)(?:\/[\w:.-]+(?=\s|$)|@[^\s]*[^\s.,;:!?)])/g;
export function promptTokens(text: string): PromptToken[] {
const out: PromptToken[] = [];
let at = 0;
for (const m of text.matchAll(TOKEN_RE)) {
if (m.index > at) out.push({ kind: "text", text: text.slice(at, m.index) });
out.push({ kind: m[0][0] === "/" ? "slash" : "mention", text: m[0] });
at = m.index + m[0].length;
}
if (at < text.length) out.push({ kind: "text", text: text.slice(at) });
return out;
}
+35
View File
@@ -0,0 +1,35 @@
/**
* @file settingsLink — where a Settings link opens. On a workspace (the
* Workspace page with `?lane=`) it is that page's Settings tab
* (`?settings=<section>`, keeping `lane` and `run`), so the terminals and chats
* stay mounted behind it; anywhere else it is the Settings page
* (`/settings/<section>`). A path is what the page takes after `/settings/`:
* `<section>[?query][#anchor]`, `""` for the default section.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useLocation } from "react-router-dom";
export function settingsHref(path: string, at: { pathname: string; search: string }): string {
const [rest = "", anchor = ""] = path.split("#");
const [section = "", query = ""] = rest.split("?");
const hash = anchor ? `#${anchor}` : "";
const here = new URLSearchParams(at.search);
const lane = here.get("lane");
if (at.pathname !== "/workspace" || !lane) {
return `/settings${section ? `/${section}` : ""}${query ? `?${query}` : ""}${hash}`;
}
// A section's own query (a sub-tab) and the session replay do not carry over.
const next = new URLSearchParams({ lane });
const run = here.get("run");
if (run) next.set("run", run);
next.set("settings", section);
for (const [k, v] of new URLSearchParams(query)) next.set(k, v);
return `/workspace?${next}${hash}`;
}
/** `settingsHref` from the current location. */
export function useSettingsHref(): (path: string) => string {
const location = useLocation();
return (path) => settingsHref(path, location);
}
+3 -1
View File
@@ -16,6 +16,7 @@
import { useEffect, useState, type ComponentType } from "react";
import { Link } from "react-router-dom";
import { useSettingsHref } from "../lib/settingsLink";
import { useTranslation } from "react-i18next";
import { Cpu, ExternalLink, FileCog, RefreshCw } from "lucide-react";
import { api, type AiToolConfigFile } from "../lib/api";
@@ -235,6 +236,7 @@ function Card({
/** The tab for one catalog tool. */
export function AiToolConfig({ toolId }: { toolId: string }) {
const { t } = useTranslation("settings");
const href = useSettingsHref();
const aiTools = useAiTools();
const tool = aiTools.byId(toolId);
const [usage, setUsage] = useState<{ sessions: number; cost: number } | null>(null);
@@ -273,7 +275,7 @@ export function AiToolConfig({ toolId }: { toolId: string }) {
</a>
)}
{tool.update && (
<Link to="/settings/agents" className="btn-ghost text-xs">
<Link to={href("agents")} className="btn-ghost text-xs">
<RefreshCw className="h-3.5 w-3.5" />
{t("toolTab.update")}
</Link>
+5 -1
View File
@@ -88,6 +88,7 @@ import { SessionRowMenu, workspaceHref } from "../components/SessionRowMenu";
import { formatDateTime, formatDuration, truncate, fmtCost, isoOrUndefined } from "../lib/format";
import { effectiveSessionStatus, sessionAwaitingReason } from "../lib/types";
import type { Session, DashboardEvent } from "../lib/types";
import { usePermissions } from "../components/AuthGate";
const PAGE_SIZE = 10;
@@ -183,14 +184,17 @@ export function Sessions({ embedded = false }: { embedded?: boolean } = {}) {
// Load remote-source labels so remote-origin rows can show a friendly badge
// instead of a raw `src_…` id. Refreshed when a source's status changes.
// Staff only: a developer's /api/remote-sources is a 403 (STAFF_ONLY_PREFIXES).
const { seesAll } = usePermissions();
const loadSourceLabels = useCallback(() => {
if (!seesAll) return;
api.remoteSources
.list()
.then((res: { sources: RemoteSource[] }) => {
setSourceLabels(new Map(res.sources.map((s) => [s.id, s.label])));
})
.catch(() => undefined);
}, []);
}, [seesAll]);
useEffect(() => {
loadSourceLabels();
+7 -5
View File
@@ -196,7 +196,7 @@ export function Settings({
section?: "data" | "about" | "notifications";
} = {}) {
const { t } = useTranslation("settings");
const { isAdmin } = usePermissions();
const { isAdmin, seesAll } = usePermissions();
// Only Data and About show server info; General and Notifications never read it.
const needsInfo = section === "data" || section === "about";
const [loading, setLoading] = useState(needsInfo);
@@ -954,10 +954,12 @@ export function Settings({
<ImportHistory />
</section>
{/* ─── REMOTE DATA SOURCES ─── */}
<section id="remote-sources" className="scroll-mt-24">
<RemoteSources />
</section>
{/* ─── REMOTE DATA SOURCES (staff only: the server answers 403 to a developer) ─── */}
{seesAll && (
<section id="remote-sources" className="scroll-mt-24">
<RemoteSources />
</section>
)}
{/* ─── PUSH INGEST + PRIVACY (each its own anchored card) ─── */}
<PushIngestSettings />
+46 -23
View File
@@ -20,6 +20,10 @@
* marks AI tools current, the search shows their matches under it), and a
* row in the AI tools list opens its tool's tab. A workspace's own settings live on its home, not here.
* The old `?tool=<id>` query and `#ai-tools` hash redirect to the path form.
* With `section` it is a workspace's Settings tab instead (`?settings=` on
* /workspace, `lib/settingsLink.ts`): the same pages, every link kept in the
* tab (replaced, as a tab click), no Back link, and Ctrl/Cmd+F left alone
* while the tab is hidden behind a session.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import {
@@ -46,6 +50,7 @@ import {
Server,
} from "lucide-react";
import { Settings } from "./Settings";
import { useSettingsHref } from "../lib/settingsLink";
import { CcConfig } from "./CcConfig";
import { CodexConfig } from "./CodexConfig";
import { AiToolConfig } from "./AiToolConfig";
@@ -55,6 +60,7 @@ import { AppearanceSettings } from "../components/settings/AppearanceSettings";
import { SnapshotSettings } from "../components/settings/SnapshotSettings";
import { ReportSettings } from "../components/settings/ReportSettings";
import { AiToolsSettings } from "../components/settings/AiToolsSettings";
import { AgentInstructionsSettings } from "../components/settings/AgentInstructionsSettings";
import { PricingSettings } from "../components/settings/PricingSettings";
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
import { useAiTools } from "../hooks/useAiTools";
@@ -143,19 +149,23 @@ const MOVED: Record<string, string> = {
// AI Proxy is not here: every user keeps personal profiles (S3).
const ADMIN_SECTIONS = new Set(["data", "pricing"]);
/** An unknown section: say so, then show Appearance. */
function UnknownSection({ name }: { name: string }) {
/** An unknown section: say so, then show Appearance (`to`). */
function UnknownSection({ name, to }: { name: string; to: string }) {
const { t } = useTranslation("settings");
useEffect(() => {
toast.info(t("settingsNav.unknownSection", { section: name }));
}, [name, t]);
return <Navigate to="/settings/appearance" replace />;
return <Navigate to={to} replace />;
}
export function SettingsHub() {
export function SettingsHub({ section }: { section?: string } = {}) {
const { t } = useTranslation("settings");
const location = useLocation();
const navigate = useNavigate();
const embedded = section !== undefined;
const href = useSettingsHref();
const nav = embedded ? { replace: true } : undefined;
const rootRef = useRef<HTMLDivElement>(null);
const aiTools = useAiTools();
const [query, setQuery] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
@@ -168,6 +178,8 @@ export function SettingsHub() {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (!(e.ctrlKey || e.metaKey) || e.key.toLowerCase() !== "f") return;
// A workspace's Settings tab hidden behind a session: not on screen.
if (rootRef.current?.closest(".hidden")) return;
// A config editor's own find keeps the shortcut.
const el = e.target;
if (el instanceof Element && el.closest("textarea, [contenteditable='true'], .cm-editor"))
@@ -257,7 +269,12 @@ export function SettingsHub() {
group: "agents",
Icon: Bot,
...s("agents"),
render: () => <AiToolsSettings settingsHref={toolHref} />,
render: () => (
<div className="space-y-6">
<AgentInstructionsSettings />
<AiToolsSettings settingsHref={toolHref} />
</div>
),
},
...(enabled("claude")
? [
@@ -381,7 +398,7 @@ export function SettingsHub() {
const toolTabs = sections.filter((x) => x.tool);
function toolHref(id: string) {
const page = toolTabs.find((x) => x.tool === id);
return page ? `/settings/${page.id}` : undefined;
return page ? href(page.id) : undefined;
}
const entries: SettingsEntry[] = sections.flatMap((sec) => [
@@ -402,7 +419,9 @@ export function SettingsHub() {
})),
]);
const rest = location.pathname.replace(/^\/settings\/?/, "").replace(/\/$/, "");
const rest = embedded
? section
: location.pathname.replace(/^\/settings\/?/, "").replace(/\/$/, "");
if (!rest) {
const params = new URLSearchParams(location.search);
const tool = params.get("tool");
@@ -412,9 +431,9 @@ export function SettingsHub() {
: tool
? (LEGACY_TOOL[tool] ?? `agents/${tool}`)
: "appearance";
return <Navigate to={`/settings/${to}`} replace />;
return <Navigate to={href(to)} replace />;
}
if (MOVED[rest]) return <Navigate to={`/settings/${MOVED[rest]}`} replace />;
if (MOVED[rest]) return <Navigate to={href(MOVED[rest])} replace />;
const active = sections.find((x) => x.id === rest);
if (!active) {
// A catalog tool page exists only once the tool list has loaded.
@@ -426,7 +445,7 @@ export function SettingsHub() {
<Skeleton className="h-40 w-full" />
</div>
);
return <UnknownSection name={rest} />;
return <UnknownSection name={rest} to={href("appearance")} />;
}
const ranked = query.trim() ? rankSettings(query, entries) : null;
@@ -437,8 +456,8 @@ export function SettingsHub() {
const navActive = navId(active.id);
const results = ranked ? rankSettingsEntries(query, entries) : [];
const titleOf = (id: string) => sections.find((x) => x.id === id)?.title ?? id;
const hrefOf = (e: SettingsEntry) => `/settings/${e.section}${e.to ?? ""}`;
const go = (id: string) => navigate(`/settings/${id}`);
const hrefOf = (e: SettingsEntry) => href(`${e.section}${e.to ?? ""}`);
const go = (id: string) => navigate(href(id), nav);
const back = (e: MouseEvent) => {
if (!canGoBack) return;
e.preventDefault();
@@ -454,7 +473,7 @@ export function SettingsHub() {
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && results[0]) navigate(hrefOf(results[0]));
if (e.key === "Enter" && results[0]) navigate(hrefOf(results[0]), nav);
if (e.key === "Escape") setQuery("");
}}
placeholder={t("search.placeholder")}
@@ -472,6 +491,7 @@ export function SettingsHub() {
<li key={`${r.section}${r.to ?? ""}${r.title}`}>
<Link
to={hrefOf(r)}
replace={embedded}
onClick={() => setQuery("")}
className={`block px-3 py-2 text-xs hover:bg-surface-3 ${i === 0 ? "bg-surface-2" : ""}`}
>
@@ -486,15 +506,17 @@ export function SettingsHub() {
);
return (
<div>
<Link
to="/workspace"
onClick={back}
className="mb-4 inline-flex items-center gap-1.5 text-xs text-fg-secondary hover:text-fg-primary"
>
<ArrowLeft className="h-3.5 w-3.5" />
{canGoBack ? t("settingsNav.backPrevious") : t("settingsNav.back")}
</Link>
<div ref={rootRef}>
{!embedded && (
<Link
to="/workspace"
onClick={back}
className="mb-4 inline-flex items-center gap-1.5 text-xs text-fg-secondary hover:text-fg-primary"
>
<ArrowLeft className="h-3.5 w-3.5" />
{canGoBack ? t("settingsNav.backPrevious") : t("settingsNav.back")}
</Link>
)}
<div className="flex gap-8">
<aside className="sticky top-0 hidden max-h-[calc(100vh-4rem)] w-60 shrink-0 self-start overflow-y-auto pb-6 md:block">
{searchBox}
@@ -516,7 +538,8 @@ export function SettingsHub() {
{items.map(({ id, title, Icon }) => (
<Link
key={id}
to={`/settings/${id}`}
to={href(id)}
replace={embedded}
aria-current={id === navActive ? "page" : undefined}
data-first-match={id === first ? "true" : undefined}
className={`relative flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors ${
+97 -36
View File
@@ -21,6 +21,10 @@
* closes, switches tab, or moves to another workspace or session (it stays on
* the old one until saved or discarded). Its bottom fold, Workspace context,
* is the home's context column compact (`WorkspaceContext`). Errors are toasts.
* Settings opened on a workspace (`?settings=<section>`, `lib/settingsLink.ts`)
* is a tab of its own at the end of the tab bar, kept until its X: the
* Settings pages (`SettingsHub`) shown instead of the panes, which stay
* mounted behind them.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -116,6 +120,8 @@ import {
updateActive,
} from "../lib/paneLayout";
import { readAllTabs } from "../lib/workspaceTabs";
import { settingsHref } from "../lib/settingsLink";
import { SettingsHub } from "./SettingsHub";
import { useAiTools } from "../hooks/useAiTools";
import { freeSlot, laneOfRun, startInFreeTab } from "../components/lanes/startSession";
import { sessionWorktreeName } from "../components/lanes/WorkspaceSessionList";
@@ -204,6 +210,7 @@ function ConsoleArea({
onOpenRun,
onRunsChanged,
allTabs = false,
settings = null,
}: {
lanes: Lane[];
selectedLaneId: number | null;
@@ -226,6 +233,14 @@ function ConsoleArea({
onRunsChanged: () => void;
/** Tabs from every workspace, not only the selected one's. */
allTabs?: boolean;
/** The Settings tab while it is open: its section, and whether it is the one
* on screen (the panes are then hidden, never unmounted). */
settings?: {
section: string;
active: boolean;
onOpen: () => void;
onClose: () => void;
} | null;
}) {
const { t } = useTranslation("lanes");
const aiTools = useAiTools();
@@ -473,7 +488,8 @@ function ConsoleArea({
const onKeys = useRef<(e: KeyboardEvent) => void>(() => undefined);
const areaRef = useRef<HTMLDivElement>(null);
onKeys.current = (e) => {
if (!attachRequest || !e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
if (!attachRequest || settings?.active || !e.altKey || e.ctrlKey || e.metaKey || e.shiftKey)
return;
// Not under a dialog, and not while the console is hidden (the Changes
// panel at full width, a replay, the home) — CSS-hidden, so by class.
if (document.querySelector('[role="dialog"]')) return;
@@ -520,9 +536,10 @@ function ConsoleArea({
onRenamed={() => setSessionsVersion((v) => v + 1)}
onClosed={(runId) => ended(keyOf(runId))}
onHome={onNewSession}
settings={settings}
// While one pane is shown, its split menus (a split pane has its own).
actions={
shown.length === 1 && shown[0] !== undefined ? (
!settings?.active && shown.length === 1 && shown[0] !== undefined ? (
<div className="flex items-center gap-1">
<SplitMenus
slot={shown[0]}
@@ -540,38 +557,48 @@ function ConsoleArea({
}
/>
)}
<PaneGrid
layout={view}
mounted={mounted}
runs={tabs}
label={label}
onChange={change}
canStart={free !== null}
tools={splitTools}
toolLabel={aiTools.label}
onNewSplit={(target, edge, tool) => void newSplit(target, edge, tool)}
stateOf={stateOf}
onStop={(slot) => void stopPane(slot)}
renderPane={(slot) => (
<LaneConsolePane
lanes={lanes}
// With `allTabs` a pane is bound to its own run's workspace.
laneId={allTabs ? Math.floor(slot / MAX_SLOTS) : selectedLaneId}
slot={slotOfKey(slot)}
binaryStatus={binaryStatus}
activeRuns={activeRuns}
onHasActiveRunChange={slot === focusSlot ? onHasActiveRunChange : undefined}
onNewSession={onNewSession}
onRunExit={() => ended(slot)}
// Only the focused pane takes the keyboard when it appears.
autoFocus={slot === focusSlot}
panelOpen={spanning.has(slot)}
attachRequest={
attachRequest && keyOf(attachRequest.runId) === slot ? attachRequest : null
}
/>
)}
/>
{settings && (
<div
data-testid="workspace-settings"
className={settings.active ? "min-h-0 flex-1 overflow-y-auto p-5 lg:p-6" : "hidden"}
>
<SettingsHub section={settings.section} />
</div>
)}
<div className={settings?.active ? "hidden" : "contents"}>
<PaneGrid
layout={view}
mounted={mounted}
runs={tabs}
label={label}
onChange={change}
canStart={free !== null}
tools={splitTools}
toolLabel={aiTools.label}
onNewSplit={(target, edge, tool) => void newSplit(target, edge, tool)}
stateOf={stateOf}
onStop={(slot) => void stopPane(slot)}
renderPane={(slot) => (
<LaneConsolePane
lanes={lanes}
// With `allTabs` a pane is bound to its own run's workspace.
laneId={allTabs ? Math.floor(slot / MAX_SLOTS) : selectedLaneId}
slot={slotOfKey(slot)}
binaryStatus={binaryStatus}
activeRuns={activeRuns}
onHasActiveRunChange={slot === focusSlot ? onHasActiveRunChange : undefined}
onNewSession={onNewSession}
onRunExit={() => ended(slot)}
// Only the focused pane takes the keyboard when it appears.
autoFocus={slot === focusSlot}
panelOpen={spanning.has(slot)}
attachRequest={
attachRequest && keyOf(attachRequest.runId) === slot ? attachRequest : null
}
/>
)}
/>
</div>
</div>
);
}
@@ -592,6 +619,14 @@ export function Workspace() {
// `?session=` — an ENDED session picked in the sidebar or Recents: its
// conversation, with a prompt box whose message resumes it (SessionReplay).
const sessionParam = runParam ? null : searchParams.get("session");
// `?settings=<section>` — the Settings tab is on screen ("" = the section it
// was last on). It stays open (`settingsTab`, its last section) until its X.
const settingsParam = searchParams.get("settings");
const [settingsTab, setSettingsTab] = useState<string | null>(null);
const settingsSection = settingsParam || settingsTab || "appearance";
useEffect(() => {
if (settingsParam !== null) setSettingsTab(settingsSection);
}, [settingsParam, settingsSection]);
const navigate = useNavigate();
// A Ctrl/Cmd+click (tab or sidebar) opens the run in a split;
// it rides on the navigation's state, never in the URL.
@@ -1058,8 +1093,9 @@ export function Workspace() {
// A workspace opens on its HOME (WorkspaceHome); `?run=` opens a session —
// the console, panes and Changes panel, which the home only hides (CSS), so
// a live terminal never unmounts behind it.
const home = !!currentLane && !runParam && !sessionParam;
const replaying = !!currentLane && !!sessionParam;
const settingsOn = !!currentLane && settingsParam !== null;
const home = !!currentLane && !runParam && !sessionParam && !settingsOn;
const replaying = !!currentLane && !!sessionParam && !settingsOn;
// The session the Analyst button opens (`/sessions/:id`): the replayed one,
// or the one the opened pane holds.
const analystSessionId = sessionParam ?? openedRun?.sessionId ?? null;
@@ -1341,6 +1377,31 @@ export function Workspace() {
)
}
onRunsChanged={refreshList}
settings={
currentLane && (settingsOn || settingsTab !== null)
? {
section: settingsSection,
active: settingsOn,
onOpen: () =>
navigate(settingsHref(settingsTab ?? "", location), {
replace: true,
}),
onClose: () => {
setSettingsTab(null);
if (!settingsOn) return;
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
next.delete("settings");
next.delete("tab");
return next;
},
{ replace: true }
);
},
}
: null
}
/>
</div>
</div>
@@ -49,6 +49,9 @@ vi.mock("../../components/settings/ReportSettings", () => ({
vi.mock("../../components/settings/AiToolsSettings", () => ({
AiToolsSettings: () => <div data-testid="ai-tools" />,
}));
vi.mock("../../components/settings/AgentInstructionsSettings", () => ({
AgentInstructionsSettings: () => <div data-testid="agent-instructions" />,
}));
vi.mock("../../components/settings/PricingSettings", () => ({
PricingSettings: ({ vendor }: { vendor: string }) => <div data-testid={`pricing-${vendor}`} />,
}));
@@ -330,3 +333,36 @@ describe("SettingsHub", () => {
off();
});
});
describe("SettingsHub as a workspace's Settings tab", () => {
it("shows the section it is given, no Back link, and keeps every link in the tab", async () => {
render(
<MemoryRouter initialEntries={["/workspace?lane=3&run=agenthub-lane-3"]}>
<Routes>
<Route
path="/workspace"
element={
<>
<SettingsHub section="appearance" />
<Where />
</>
}
/>
</Routes>
</MemoryRouter>
);
expect(
await screen.findByRole("heading", { level: 1, name: "Appearance" })
).toBeInTheDocument();
expect(screen.queryByText(/^Back/)).toBeNull();
const notifications = within(nav()).getByRole("link", { name: "Notifications" });
expect(notifications).toHaveAttribute(
"href",
"/workspace?lane=3&run=agenthub-lane-3&settings=notifications"
);
fireEvent.click(notifications);
expect(screen.getByTestId("where")).toHaveTextContent(
"/workspace?lane=3&run=agenthub-lane-3&settings=notifications"
);
});
});
@@ -101,6 +101,11 @@ vi.mock("../../lib/confirm", () => ({
confirmDiscard: vi.fn(async () => true),
}));
// An ended session's replay (its own suite): a stub that names its session.
vi.mock("../SettingsHub", () => ({
SettingsHub: ({ section }: { section?: string }) => (
<div data-testid="settings-hub">{section}</div>
),
}));
vi.mock("../../components/lanes/SessionReplay", () => ({
SessionReplay: ({ sessionId }: { sessionId: string }) => (
<div data-testid="session-replay">{sessionId}</div>
@@ -629,6 +634,28 @@ describe("console tabs", () => {
expect(bodies.filter((b) => b.closest(".hidden"))).toHaveLength(1);
});
it("opens Settings as a tab of its own, the console only hidden, kept until its X", async () => {
withRuns([live(0)]);
await open("/workspace?lane=1&run=agenthub-lane-1&settings=agents");
const user = userEvent.setup();
expect(screen.getByTestId("settings-hub")).toHaveTextContent("agents");
expect(screen.getByTestId("console-tab-settings")).toHaveAttribute("aria-selected", "true");
expect(screen.getByTestId("console-body").closest(".hidden")).not.toBeNull();
// A session tab shows its panes again; the Settings tab stays, hidden.
await user.click(screen.getByTestId("console-tab-0"));
expect(screen.getByTestId("console-body").closest(".hidden")).toBeNull();
expect(screen.getByTestId("workspace-settings")).toHaveClass("hidden");
expect(screen.getByTestId("console-tab-settings")).toHaveAttribute("aria-selected", "false");
// Back on it: the section it was on.
await user.click(screen.getByTestId("console-tab-settings"));
expect(screen.getByTestId("workspace-settings")).not.toHaveClass("hidden");
expect(screen.getByTestId("settings-hub")).toHaveTextContent("agents");
await user.click(screen.getByTestId("console-tab-settings-close"));
expect(screen.queryByTestId("console-tab-settings")).toBeNull();
expect(screen.queryByTestId("settings-hub")).toBeNull();
expect(screen.getByTestId("console-body").closest(".hidden")).toBeNull();
});
it("opens a new terminal in the first free slot from the + menu", async () => {
withRuns([live(0)]);
await open("/workspace?lane=1&run=agenthub-lane-1");
@@ -5580,30 +5580,34 @@ exports[`screen snapshots > Run 1`] = `
class="flex min-h-0 flex-1 flex-col"
>
<div
class="relative min-h-0 flex-1"
data-testid="pane-grid"
class="contents"
>
<div
class="absolute flex flex-col"
data-focused="true"
data-menu-bounds="true"
data-testid="pane-0"
style="left: 0%; top: 0%; width: 100%; height: 100%;"
class="relative min-h-0 flex-1"
data-testid="pane-grid"
>
<div
class="flex min-h-0 flex-1 flex-col"
class="absolute flex flex-col"
data-focused="true"
data-menu-bounds="true"
data-testid="pane-0"
style="left: 0%; top: 0%; width: 100%; height: 100%;"
>
<div
class="flex min-h-0 flex-1 flex-col gap-3"
data-testid="console-body"
class="flex min-h-0 flex-1 flex-col"
>
<div
class="m-3 flex flex-1 flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border p-6 text-center text-sm text-fg-muted"
data-testid="pane-no-run"
class="flex min-h-0 flex-1 flex-col gap-3"
data-testid="console-body"
>
<p>
No session in this pane. Start one from the workspace's prompt box.
</p>
<div
class="m-3 flex flex-1 flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border p-6 text-center text-sm text-fg-muted"
data-testid="pane-no-run"
>
<p>
No session in this pane. Start one from the workspace's prompt box.
</p>
</div>
</div>
</div>
</div>
@@ -7174,7 +7178,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span
class="text-status-warning/90"
>
UI build v2.7.1 (rebuild client to match)
UI build v2.7.3 (rebuild client to match)
</span>
</p>
</div>
+5 -1
View File
@@ -97,6 +97,8 @@ An admin changes accounts with `POST /api/users` (`{username, password, role}`
| --- | --- |
| `GET /api/me/prefs` | `{prefs: {<key>: "<value>"}, onboardedAt}`; `401 EAUTH` with no login |
| `PUT /api/me/prefs` `{prefs: {<key>: "<value>" \| null}}` | merged (`null` deletes a key), the same shape back; `400 EBADPREFS` for a key outside the list below or a value that is not a string of at most 8 KB |
| `GET /api/me/instructions` | `{instructions, max, tools}` — `tools` lists the AI tool ids whose new panes receive them (`["claude","codex"]`; Cursor CLI and Antigravity have no per-session way to take them). The account's own agent instructions (Settings → AI tools → My agent instructions, `""` when none); `401 EAUTH` with no login |
| `PUT /api/me/instructions` `{instructions: "<markdown>" \| null}` | replaced, the same shape back; blank or `null` clears; `400 EBADFIELD` not a string, `413 ETOOBIG` over 16 000 characters. Put BEFORE the workspace's own instructions in every new Claude/Codex pane of a workspace the account owns (local mode: every workspace) — see Workspace context below |
| `POST /api/me/onboarded` | the first-run setup is done or skipped (kept from the first call) |
The keys are the browser's own localStorage names: `theme`, `agenthub.theme.dark`, `agenthub.theme.light`, `i18nextLng`, `agenthub.terminal.font`, `agenthub.pet`, `agenthub.pet.size`, `agent-dashboard-sagi-enabled`, `agent-dashboard-sagi-muted`, `agent-monitor-notifications`, `agenthub.defaultTool`, `agenthub.permissionMode` (never `bypassPermissions` — bypass stays the per-session switch), `agenthub.tour`, and `agenthub.chatUi` (`"1"` = Claude/Codex sessions open as a chat; read and written on the server only, by `client/src/lib/chatUi.ts`). The request never names an account, and writes are same-origin. An account's save writes only their own row, and under S3 a `theme` of `dark` or `light` also becomes the `theme` in their own `~/.claude/settings.json`, written as them.
@@ -355,6 +357,8 @@ Error `409 ESESSIONLIVE` is returned when `resumeSessionId` (or `resume`) names
Error `400 EBADPROMPT` is returned for a `prompt` (or `POST /api/run`'s `initialPrompt`) over 128 KB: no CLI can take one argument that long (Linux's `MAX_ARG_STRLEN`, issue #68) — attach the text as a file instead. A prompt over 4 KB never rides in tmux's argv (one tmux message holds 16 KB, `command too long`): outside Windows it is written to `<data dir>/run-prompts/<run id>.txt` (0600) and the pane's own `sh` reads it back as the CLI's last argument and deletes the file. An `after-start` tool's paste still goes through `tmux set-buffer` and stays under that 16 KB.
Error `409 ENOCWD` is returned when the workspace's folder no longer exists (or is not a directory) — the pane would otherwise start in the account's home under this workspace's name. A folder this server cannot read (`EACCES`) is not refused.
Error `409 ERUNLIVE` is returned if the workspace already has a live run; stop it first with the `stop` action. The same code and status come back when the workspace's tmux pane is already running an agent while a `prompt` or `resumeSessionId` was sent — that argv cannot be typed over a live agent, so the request is refused instead of answered 200 with nothing done. A start that asks for neither still adopts the existing pane silently.
#### Preflight a destructive workspace action
@@ -1386,7 +1390,7 @@ What a workspace's home page edits (`server/lib/lane-sources.js`, `server/routes
- **Memory** — read-only: the folder's `CLAUDE.md`, `.claude/CLAUDE.md`, `CLAUDE.local.md`, `AGENTS.md`, and for a Claude workspace `<CLAUDE_HOME>/projects/<encoded cwd>/memory/*.md` — `{files: [{label, path, size, mtime, truncated, content}]}`, each at most 256 KB of content.
- Every write is same-origin only (`403 EBADORIGIN`); an unknown workspace is `404 ENOLANE`, an unknown source `404 ENOSOURCE`.
At spawn (`spawnRun`, both `POST /api/lanes/:id/start` and `POST /api/run`) the pane gets, from these rows: Claude — `--add-dir <folders…> [<context dir>]` first, then `--append-system-prompt-file=<data dir>/workspace-prompts/<lane id>.md`, a file rewritten at every spawn holding the instructions + the context file list + the extra folders; Codex — one `--add-dir <dir>` per folder (and the context dir) and `-c developer_instructions=<same text as a TOML string>` (above 6 KB as the escaped TOML string, one line pointing at that file instead; nothing when that file could not be written). Any other tool gets none of it (`supported: false`). A folder or file that vanished is skipped.
At spawn (`spawnRun`, both `POST /api/lanes/:id/start` and `POST /api/run`) the pane gets, from these rows: Claude — `--add-dir <folders…> [<context dir>]` first, then `--append-system-prompt-file=<data dir>/workspace-prompts/<lane id>.md`, a file rewritten at every spawn holding the workspace owner's own instructions (`PUT /api/me/instructions`) + the workspace's instructions + the context file list + the extra folders; Codex — one `--add-dir <dir>` per folder (and the context dir) and `-c developer_instructions=<same text as a TOML string>` (above 6 KB as the escaped TOML string, one line pointing at that file instead; nothing when that file could not be written). Any other tool gets none of it (`supported: false`). A folder or file that vanished is skipped.
#### Projects (workspaces grouped by repository)
+5 -5
View File
File diff suppressed because one or more lines are too long
+28 -3
View File
@@ -797,8 +797,33 @@ agenthub users provision alice # the Linux account only (a user made before
`tmux`, `git` or `gh` moves to another path (sudoers allows only the
recorded ones).
- Keep every other AgentHub server off the same database (a dev server on
another port): an older build cannot see members' tmux servers and marks
their runs abandoned.
another port). A server without S3 cannot see members' tmux servers, so from
v2.7.2 on it leaves any run and workspace under the users root (read from
`/etc/agenthub/server.conf`) alone; a build older than v2.7.2 marks their
runs interrupted or abandoned within seconds, and the proxy gateway then
refuses those panes (`401 not a live pane's token`). A dev server that DOES
run in server mode (it read `AGENTHUB_MODE=server` from
`~/.config/agenthub/settings.env`) but lacks the unit's
`CAP_DAC_READ_SEARCH` cannot stat a member's 0700 `.agenthub/tmux`; from
v2.7.3 on that `EACCES` counts as "maybe running" and the member's list is
asked through sudo, never read as "no panes". And a run younger than 20 s
is never ended by any server: only the server that started a pane writes it
into its own cached tmux list, so another one on the same database had not
listed it yet and ended a Codex pane seconds after its start.
- **Update now** under the system unit restarts through
`sudo -n systemctl restart --no-block agenthub.service` (from v2.7.3). An
older build started a detached replacement instead, which kept the port
while `Restart=always` started the unit every few seconds, each start exiting
"already running" — `systemctl status agenthub.service` shows
`activating (auto-restart)` with `MainPID` 0. Recover with
`kill -TERM <pid of the server on the port>`; the unit then starts on the
new code.
- A member's tmux list is re-asked at most every 5 s (one `sudo` each, which
writes three `auth.log` lines); a pane the dashboard starts or stops shows at
once, one that exits on its own within those 5 s.
- Claude Code does not carry a folder's trust down from the member's home
(which provisioning trusts): a member answers "Trust this folder" once per
workspace, in the terminal.
### Undo and uninstall
@@ -847,7 +872,7 @@ To remove everything:
| `403 EPEER` on a hook | The hook came from an account that is neither the host nor an enabled member, or the uid could not be read. |
| `403 EFOREIGN` on a hook | A member's hook named a folder, session or pane outside their own home. |
| `403 EFOREIGN` on a file view or save | The workspace's folder is now a link into another account's files. Put the real folder back, or make a workspace on the real one. |
| A member's runs show as abandoned | Another, older AgentHub server on the same database (see Upgrade). |
| A member's runs show as abandoned or interrupted, or Claude in a member's pane gets `401 not a live pane's token` | Another AgentHub server older than v2.7.2 on the same database (see Upgrade). |
| `sudo: a password is required` in the server log | The sudoers file is missing, or a tool moved off its recorded path: re-run `install-system`. |
| `[drop-caps] setpriv is missing` | Install util-linux's `setpriv` at `/usr/bin/setpriv`; until then the dashboard's children inherit `CAP_DAC_READ_SEARCH`. |
| `[plugin-baseline] <name>: claude is not in the shared tools yet` | `claude` is not published to `/opt/agenthub/tools` yet: `agenthub server tools`, then `agenthub users provision <name>`. |
+88 -1
View File
@@ -3,7 +3,7 @@
openapi: 3.0.3
info:
title: Agent Dashboard for Claude Code API
version: 2.7.1
version: 2.7.3
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ĩ
@@ -13822,6 +13822,93 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/me/instructions:
get:
tags:
- Auth
summary: The account's own agent instructions
description: Put before the workspace's own instructions in every new Claude/Codex pane of a workspace the account owns.
responses:
'200':
description: Instructions
content:
application/json:
schema:
type: object
required:
- instructions
- max
- tools
properties:
instructions:
type: string
max:
type: integer
tools:
type: array
items:
type: string
description: AI tool ids whose new panes receive the instructions (claude, codex)
'401':
description: EAUTH
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
put:
tags:
- Auth
summary: Replace the account's agent instructions (blank or null clears)
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
instructions:
type: string
nullable: true
maxLength: 16000
responses:
'200':
description: Saved
content:
application/json:
schema:
type: object
required:
- instructions
- max
- tools
properties:
instructions:
type: string
max:
type: integer
tools:
type: array
items:
type: string
description: AI tool ids whose new panes receive the instructions (claude, codex)
'400':
description: EBADFIELD — not a string
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'401':
description: EAUTH
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'413':
description: ETOOBIG — longer than 16 000 characters
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/me/onboarded:
post:
tags:
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "2.7.1",
"version": "2.7.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.7.1",
"version": "2.7.3",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.7.1",
"version": "2.7.3",
"bin": {
"agenthub": "bin/agenthub.js"
},
+10 -2
View File
@@ -16,7 +16,9 @@
* setting never writes the developer's real `~/.config/agenthub/settings.env`,
* and `AGENTHUB_PRICING_SYNC=0` keeps every suite off the network. The
* `GIT_DIR`-family variables a git hook exports are dropped too, so a test's
* own `git init`/`commit` can never land in the real repository.
* own `git init`/`commit` can never land in the real repository. Every
* inherited `AGENTHUB_*` variable is dropped as well: a dashboard pane carries
* `AGENTHUB_MODE=server` and friends, which turned the suites login-gated.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -34,8 +36,14 @@ const files = fs
const args = process.argv.slice(2);
const tmuxDir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-test-tmux-"));
// A dashboard pane's own AGENTHUB_* (AGENTHUB_MODE=server, AGENTHUB_USERS_ROOT,
// its pane token…) would turn the suites into a login-gated server: hundreds
// of 401s and EFOLDERs that have nothing to do with the code under test.
const inherited = Object.fromEntries(
Object.entries(process.env).filter(([k]) => !k.startsWith("AGENTHUB_"))
);
const env = {
...process.env,
...inherited,
TMUX_TMPDIR: tmuxDir,
// Never the developer's real ~/.config/agenthub/settings.env: it can pin
// DASHBOARD_DATA_DIR at their live database.
+18 -1
View File
@@ -22,8 +22,17 @@ before(() => {
});
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
// Long enough ago that no reconcile treats the run as just started.
const start = (id, extra = {}) =>
runs.recordRun({ id, mode: "interactive", cwd: "/r", status: "running", laneId: 4, ...extra });
runs.recordRun({
id,
mode: "interactive",
cwd: "/r",
status: "running",
laneId: 4,
startedAt: Date.parse("2026-01-01T00:00:00Z"),
...extra,
});
describe("dashboard-runs", () => {
it("keeps the previous run of a pane as history when the pane starts again", () => {
@@ -77,6 +86,14 @@ describe("dashboard-runs", () => {
assert.ok(runs.getRun("agenthub-lane-11"), "another tool's row stays");
});
it("never ends a run started a moment ago: another process's tmux list may predate it", () => {
start("agenthub-lane-30", { laneId: 30, startedAt: Date.now() - 2000 });
start("agenthub-lane-31", { laneId: 31, startedAt: Date.now() - 60_000 });
runs.reconcileOrphans(() => false);
assert.equal(runs.getRun("agenthub-lane-30").status, "running");
assert.equal(runs.getRun("agenthub-lane-31").status, "abandoned");
});
it("flags as abandoned only the running rows whose pane is gone", () => {
start("agenthub-lane-5", { laneId: 5 });
start("agenthub-lane-6", { laneId: 6 });
+11
View File
@@ -238,6 +238,17 @@ describe("lane-sources instructions and launch", () => {
src.setInstructions(codexLane.id, null);
});
it("puts the owner's own agent instructions before the workspace's", () => {
src.setUserInstructions("local", "# my style\n- small diffs");
const { systemPrompt, systemPromptFile } = src.launchExtras(lane.id, "claude");
assert.match(systemPrompt, /^# my style\n- small diffs\n\n- always answer in Vietnamese/);
assert.equal(fs.readFileSync(systemPromptFile, "utf8"), systemPrompt);
// A workspace with nothing of its own still gets them.
assert.match(src.launchExtras(otherLane.id, "claude").systemPrompt, /^# my style/);
src.setUserInstructions("local", null);
assert.equal(src.launchExtras(otherLane.id, "claude").systemPrompt, null);
});
it("gives an unsupported tool nothing, and skips a vanished folder", () => {
assert.deepEqual(src.launchExtras(lane.id, "gemini"), {
addDirs: [],
+10
View File
@@ -427,6 +427,16 @@ describe("lanes changed by another server on the same DB", () => {
});
describe("lane start input", () => {
it("refuses to start in a workspace folder that no longer exists (409 ENOCWD)", async () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-gone-"));
const id = (await request("POST", "/api/lanes", { cwd })).body.lane.id;
fs.rmSync(cwd, { recursive: true, force: true });
// Without the check the pane started in the account's home instead.
const r = await request("POST", `/api/lanes/${id}/start`, {});
assert.equal(r.status, 409);
assert.equal(r.body.error.code, "ENOCWD");
});
it("answers 400 for a model that is not a plain model id", async () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-bad-model-"));
const id = (await request("POST", "/api/lanes", { cwd })).body.lane.id;
+29
View File
@@ -74,6 +74,22 @@ describe("local mode: one account", () => {
}
});
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"] });
r = await call("PUT", "/api/me/instructions", {
body: { instructions: "- answer in Vietnamese\r\n" },
});
assert.equal(r.status, 200);
assert.equal(r.body.instructions, "- answer in Vietnamese\n");
r = await call("PUT", "/api/me/instructions", { body: { instructions: 1 } });
assert.equal(r.body.error.code, "EBADFIELD");
r = await call("PUT", "/api/me/instructions", { body: { instructions: "x".repeat(16001) } });
assert.equal(r.status, 413);
r = await call("PUT", "/api/me/instructions", { body: { instructions: " " } });
assert.equal(r.body.instructions, "");
});
it("marks the first-run setup done, once", async () => {
const r = await call("POST", "/api/me/onboarded");
assert.match(r.body.onboardedAt, /^\d{4}-/);
@@ -117,6 +133,19 @@ describe("server mode: per account", () => {
assert.deepEqual(m, { prefs: { i18nextLng: "en" }, onboardedAt: null });
});
it("keeps each account's own agent instructions", async () => {
assert.equal((await call("GET", "/api/me/instructions")).status, 401);
await call("PUT", "/api/me/instructions", { cookie: mia, body: { instructions: "mia's" } });
assert.equal(
(await call("GET", "/api/me/instructions", { cookie: mia })).body.instructions,
"mia's"
);
assert.equal(
(await call("GET", "/api/me/instructions", { cookie: max })).body.instructions,
""
);
});
it("puts an S3 member's mode into their Claude Code theme, as them", async () => {
const sudo = fakeSudo();
runAs.__setExecImpl(sudo.exec);
+5 -7
View File
@@ -335,17 +335,15 @@ describe("GET /api/run/drop/:name", () => {
});
it("serves no file that is not a dropped image or text file", async () => {
const link = path.join(
os.tmpdir(),
`agenthub-drops-${os.userInfo().uid}`,
"fedcba9876543210.png"
);
fs.rmSync(link, { force: true });
fs.symlinkSync("/etc/passwd", link);
const pdf = await fetchJson("/api/run/drop-file?name=a.pdf", {
method: "POST",
raw: { type: "application/pdf", buf: Buffer.from("%PDF") },
});
// Planted in the real drop dir (the one this drop just made), not a
// guessed path: `<data dir>/drops` replaced `/tmp/agenthub-drops-<uid>`.
const link = path.join(path.dirname(pdf.body.path), "fedcba9876543210.png");
fs.rmSync(link, { force: true });
fs.symlinkSync("/etc/passwd", link);
for (const name of [
path.basename(pdf.body.path), // a drop, but no image
"0123456789abcdef.png", // the right shape, never dropped
@@ -0,0 +1,68 @@
/**
* @file s3-unseen-member-runs.test.js
* @description A dashboard WITHOUT S3 (local mode, e.g. a dev server) sharing
* the database of an S3 server must never end a member's run or release a
* member's workspace: their panes live on the member's own tmux server, which
* it cannot ask. Before the fix its heal beat marked every member run
* `interrupted` within 10 s, and the proxy gateway then refused the pane.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, 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 ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "s3-unseen-"));
const USERS = path.join(ROOT, "users");
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
process.env.AGENTHUB_SERVER_CONF = path.join(ROOT, "server.conf");
delete process.env.AGENTHUB_MODE; // local mode: this server has no S3
fs.writeFileSync(
process.env.AGENTHUB_SERVER_CONF,
`HOST_USER=host\nHOST_UID=1001\nUSERS_ROOT=${USERS}\n`
);
const runs = require("../lib/dashboard-runs");
const { db } = require("../db");
const { healAllRunIds } = require("../routes/lanes");
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
const start = (id, laneId, cwd) =>
runs.recordRun({
id,
mode: "interactive",
cwd,
status: "running",
laneId,
sessionId: `s-${id}`,
startedAt: Date.parse("2026-01-01T00:00:00Z"),
});
describe("a server without S3 on an S3 machine's database", () => {
it("never ends a member's run, and still ends its own", () => {
start("agenthub-lane-1", 1, path.join(USERS, "mia", "proj"));
start("agenthub-lane-2", 2, "/home/host/proj");
runs.reconcileOrphans(() => false);
assert.equal(runs.getRun("agenthub-lane-1").status, "running");
assert.equal(runs.getRun("agenthub-lane-2").status, "interrupted");
});
it("never releases a member's workspace", () => {
const lane = db.prepare(
"INSERT INTO lanes (title, cwd, run_id, status) VALUES (?, ?, ?, 'running')"
);
const member = lane.run(
"m",
path.join(USERS, "mia", "proj"),
"agenthub-lane-1"
).lastInsertRowid;
const host = lane.run("h", "/home/host/proj", "agenthub-lane-2").lastInsertRowid;
healAllRunIds(new Set());
const runId = (id) => db.prepare("SELECT run_id FROM lanes WHERE id = ?").get(id).run_id;
assert.equal(runId(member), "agenthub-lane-1");
assert.equal(runId(host), null);
});
});
+88
View File
@@ -0,0 +1,88 @@
/**
* @file self-restart.test.js
* @description After an update, a server our service unit runs is restarted
* by the unit, never by a detached replacement: v2.7.2 on the S3 system unit
* left the replacement holding the port while `Restart=always` started the
* unit every 3 s, each start exiting "already running".
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const { scheduleRestart, owningUnit } = require("../lib/self-restart");
function deps(unit, restartAnswer = "restarted") {
const calls = { restart: [], kill: 0, spawn: 0 };
return {
calls,
unit,
svc: () => ({
restartUserService: (name) => {
calls.restart.push(name);
return restartAnswer;
},
}),
kill: () => calls.kill++,
spawnHelper: () => {
calls.spawn++;
return { unref() {} };
},
};
}
describe("self-restart", () => {
it("tells which unit owns this process, from its cgroup or launchd label", () => {
const linux = (text) => owningUnit({ platform: "linux", cgroup: () => text });
assert.equal(linux("0::/system.slice/agenthub.service\n"), "systemd");
assert.equal(
linux("0::/user.slice/user-1001.slice/user@1001.service/app.slice/agenthub.service\n"),
"systemd"
);
assert.equal(linux("0::/user.slice/user-1001.slice/session-3.scope\n"), null);
assert.equal(linux("0::/system.slice/agenthub.service.d/x\n"), null);
assert.equal(
owningUnit({
platform: "linux",
cgroup: () => {
throw new Error("no /proc");
},
}),
null
);
assert.equal(
owningUnit({ platform: "darwin", env: { XPC_SERVICE_NAME: "io.smartgift.agenthub" } }),
"launchd"
);
assert.equal(owningUnit({ platform: "darwin", env: {} }), null);
});
it("asks systemd to restart its unit and starts no replacement", () => {
const d = deps("systemd");
assert.equal(scheduleRestart("/r", d), "service");
assert.deepEqual(d.calls.restart, ["agenthub"]);
assert.equal(d.calls.spawn, 0);
assert.equal(d.calls.kill, 0, "systemd's restart job stops this process");
});
it("exits for Restart=always when systemd cannot be asked — still no replacement", () => {
const d = deps("systemd", "failed");
assert.equal(scheduleRestart("/r", d), "exit");
assert.equal(d.calls.kill, 1);
assert.equal(d.calls.spawn, 0);
});
it("exits under launchd, whose KeepAlive starts the new code", () => {
const d = deps("launchd");
assert.equal(scheduleRestart("/r", d), "exit");
assert.equal(d.calls.kill, 1);
assert.equal(d.calls.spawn, 0);
assert.deepEqual(d.calls.restart, []);
});
it("hands over to the detached helper when no unit owns the server", () => {
const d = deps(null);
assert.equal(scheduleRestart("/r", d), "helper");
assert.equal(d.calls.spawn, 1);
assert.equal(d.calls.kill, 1);
});
});
+16
View File
@@ -248,4 +248,20 @@ describe("run-as: a path under the users root belongs to that user", () => {
"/opt/agenthub/libexec/write-as"
);
});
it("counts a member's tmux it cannot stat as maybe there, a missing one as none", () => {
const home = path.join(TMP, "users", "zoe");
const acct = { name: "zoe", login: "ah-zoe", home, uid: 5002 };
assert.equal(runAs.mayHaveTmux(acct), false, "no .agenthub at all");
const dir = path.join(home, ".agenthub", "tmux");
fs.mkdirSync(dir, { recursive: true });
assert.equal(runAs.mayHaveTmux(acct), false, "no socket yet");
// The member's own 0700 dir, seen by a dashboard without CAP_DAC_READ_SEARCH.
fs.chmodSync(dir, 0o000);
try {
if (process.getuid() !== 0)
assert.equal(runAs.mayHaveTmux(acct), true, "EACCES proves nothing");
} finally {
fs.chmodSync(dir, 0o700);
}
});
});
+7
View File
@@ -1778,6 +1778,13 @@ db.exec(`
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
`);
// An account's own agent instructions, put before every workspace's in each
// pane it owns (server/lib/lane-sources.js, issue #67). Additive.
try {
db.prepare("SELECT instructions FROM user_prefs LIMIT 1").get();
} catch {
db.prepare("ALTER TABLE user_prefs ADD COLUMN instructions TEXT").run();
}
// The `member` role was renamed `developer`: rebuild `users` for the new
// CHECK, foreign keys off so `auth_sessions` (ON DELETE CASCADE) keeps every
+17 -2
View File
@@ -258,8 +258,21 @@ function claimToolSessions(sessionId = null) {
}
const runningStmt = db.prepare(
"SELECT id, tool, session_id FROM dashboard_runs WHERE status IN ('running', 'spawning')"
"SELECT id, tool, session_id, cwd, started_at FROM dashboard_runs WHERE status IN ('running', 'spawning')"
);
/**
* How long a new run is never judged dead. A tmux list is a cached answer —
* a member's may be up to 15 s old (tmux.js MEMBER_LIST_MAX_AGE_MS) — and only
* the process that started a pane writes it into its own cache, so another
* dashboard on the same database (a dev server) ended a pane it had simply
* not listed yet: the proxy gateway then answered 401 to a live Codex.
*/
const YOUNG_RUN_MS = 20_000;
function isYoungRun(startedAt, now = Date.now()) {
const t = Date.parse(startedAt || "");
return Number.isFinite(t) && now - t < YOUNG_RUN_MS;
}
const endStmt = db.prepare(`
UPDATE dashboard_runs
SET status = @status,
@@ -298,8 +311,9 @@ const pruneShellsStmt = db.prepare(`
function reconcileOrphans(isAlive = () => false) {
try {
let changes = 0;
const { unseenMemberFolder } = require("./linux-accounts");
for (const row of runningStmt.all()) {
if (isAlive(row.id)) continue;
if (isAlive(row.id) || unseenMemberFolder(row.cwd) || isYoungRun(row.started_at)) continue;
const interrupted =
row.tool !== "shell" &&
!!row.session_id &&
@@ -367,6 +381,7 @@ module.exports = {
getRun,
forgetShellRun,
reconcileOrphans,
isYoungRun,
listInterrupted,
dismissInterrupted,
proxyForSession,
+38 -4
View File
@@ -9,7 +9,9 @@
* name it chooses, so a request never names a path on disk. Only Claude
* (`--add-dir`, `--append-system-prompt-file=`) and Codex (`--add-dir`,
* `-c developer_instructions=`) can take them; every other tool is reported
* unsupported and its panes get nothing.
* unsupported and its panes get nothing. The workspace OWNER's own agent
* instructions (`user_prefs.instructions`, Settings → My agent instructions)
* go first, before the workspace's.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
"use strict";
@@ -185,8 +187,8 @@ function removeLaneContext(laneId) {
fs.rmSync(path.join(getDataDir(), "workspace-prompts", `${Number(laneId)}.md`), { force: true });
}
function setInstructions(laneId, text) {
laneRow(laneId);
/** Instructions text as stored: a string of at most MAX_INSTRUCTIONS, blank ⇒ null. */
function cleanInstructions(text) {
if (text !== null && typeof text !== "string") {
throw fail("EBADFIELD", "instructions must be a string or null");
}
@@ -194,9 +196,37 @@ function setInstructions(laneId, text) {
if (value && value.length > MAX_INSTRUCTIONS) {
throw fail("ETOOBIG", `instructions may be at most ${MAX_INSTRUCTIONS} characters`);
}
return value;
}
function setInstructions(laneId, text) {
laneRow(laneId);
const value = cleanInstructions(text);
db.prepare("UPDATE lanes SET instructions = ? WHERE id = ?").run(value, laneId);
}
function setUserInstructions(userKey, text) {
const value = cleanInstructions(text);
db.prepare(
`INSERT INTO user_prefs (user_key, instructions) VALUES (?, ?)
ON CONFLICT(user_key) DO UPDATE SET instructions = excluded.instructions,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`
).run(userKey, value);
}
function getUserInstructions(userKey) {
return (
db.prepare("SELECT instructions FROM user_prefs WHERE user_key = ?").get(userKey)
?.instructions || null
);
}
/** The owner's own instructions: `local` in local mode, `u<owner_id>` in server mode. */
function ownerInstructions(ownerId) {
if (!require("./auth").isServerMode()) return getUserInstructions("local");
return ownerId ? getUserInstructions(`u${ownerId}`) : null;
}
/**
* What a pane of this workspace is started with, read from its rows: the
* extra folders and (when any file was uploaded) the context dir to allow, and
@@ -207,7 +237,7 @@ function setInstructions(laneId, text) {
function launchExtras(laneId, tool) {
const empty = { addDirs: [], systemPrompt: null, systemPromptFile: null };
if (!supports(tool)) return empty;
const lane = db.prepare("SELECT instructions FROM lanes WHERE id = ?").get(laneId);
const lane = db.prepare("SELECT instructions, owner_id FROM lanes WHERE id = ?").get(laneId);
if (!lane) return empty;
const rows = db
.prepare("SELECT kind, path FROM lane_sources WHERE lane_id = ? ORDER BY id ASC")
@@ -238,6 +268,8 @@ function launchExtras(laneId, tool) {
}
const addDirs = [...folders, ...(files.length ? [ctx] : [])];
const parts = [];
const mine = ownerInstructions(lane.owner_id);
if (mine) parts.push(mine);
if (lane.instructions) parts.push(lane.instructions);
if (files.length) {
parts.push(
@@ -366,6 +398,8 @@ module.exports = {
removeLaneContext,
setInstructions,
launchExtras,
setUserInstructions,
getUserInstructions,
codexInstructions,
tomlString,
INLINE_PROMPT_BYTES,
+15
View File
@@ -70,6 +70,20 @@ function s3Enabled() {
return require("./auth").isServerMode() && serverConf() !== null;
}
/**
* A member's folder (under this machine's S3 users root) while THIS server
* runs without S3 — a local-mode or dev dashboard on the same database. Its
* panes live on the member's own tmux server, which such a server cannot
* ask, so it must never judge them dead.
*/
function unseenMemberFolder(cwd) {
if (typeof cwd !== "string" || !cwd || s3Enabled()) return false;
const conf = serverConf();
if (!conf) return false;
const rel = path.relative(conf.usersRoot, path.resolve(cwd));
return !!rel && !rel.startsWith("..") && !path.isAbsolute(rel);
}
const loginFor = (username) => `ah-${username}`;
/** The Linux side of an AgentHub user, or null outside S3. */
@@ -129,6 +143,7 @@ module.exports = {
LINUX_NAME_RE,
serverConf,
s3Enabled,
unseenMemberFolder,
accountFor,
assertLinuxName,
provision,
+24 -12
View File
@@ -133,13 +133,22 @@ function memberHomes() {
function membersWithTmux() {
return memberHomes()
.map((home) => accountForPath(home))
.filter((acct) => {
try {
return fs.statSync(tmuxSocket(acct)).isSocket();
} catch {
return false;
}
});
.filter(mayHaveTmux);
}
/**
* Whether the member may have a tmux server. Only a missing socket says no:
* a member's `.agenthub/tmux` is theirs (0700), and a dashboard without
* CAP_DAC_READ_SEARCH (a dev server on the same database) gets EACCES there
* — that proves nothing, and reading it as "no server" ended every run of
* theirs within seconds. Their list is asked through sudo, which can tell.
*/
function mayHaveTmux(acct) {
try {
return fs.statSync(tmuxSocket(acct)).isSocket();
} catch (err) {
return err.code !== "ENOENT" && err.code !== "ENOTDIR";
}
}
// uid by login, from /etc/passwd (world-readable; node has no getpwnam).
@@ -357,8 +366,7 @@ function openSafe(file) {
// at no cost): MEMBER_READ_MAX unless the caller passes its own `max`, and
// never past the size `fstat` gave — a file growing under the read is cut.
const MEMBER_READ_MAX = 64 * 1024 * 1024;
const tooBig = (file) =>
Object.assign(new Error(`${file}: too big to read`), { code: "ETOOBIG" });
const tooBig = (file) => Object.assign(new Error(`${file}: too big to read`), { code: "ETOOBIG" });
/** `fs.readFileSync(file, enc)`, guarded (a member's file: ≤ `max` bytes). */
function readFileSafe(file, enc, { max = MEMBER_READ_MAX } = {}) {
@@ -369,7 +377,7 @@ function readFileSafe(file, enc, { max = MEMBER_READ_MAX } = {}) {
if (size > max) throw tooBig(file);
const buf = Buffer.alloc(size);
let off = 0;
for (let n; off < size && (n = fs.readSync(fd, buf, off, size - off, off)) > 0; ) off += n;
for (let n; off < size && (n = fs.readSync(fd, buf, off, size - off, off)) > 0;) off += n;
return enc ? buf.toString(enc, 0, off) : buf.subarray(0, off);
} finally {
fs.closeSync(fd);
@@ -398,7 +406,7 @@ async function readFileSafeAsync(file, enc, { max = MEMBER_READ_MAX } = {}) {
if (size > max) throw tooBig(file);
const buf = Buffer.alloc(size);
let off = 0;
for (let n; off < size && (n = (await fh.read(buf, off, size - off, off)).bytesRead) > 0; )
for (let n; off < size && (n = (await fh.read(buf, off, size - off, off)).bytesRead) > 0;)
off += n;
return enc ? buf.toString(enc, 0, off) : buf.subarray(0, off);
} finally {
@@ -438,7 +446,10 @@ async function* readLines(file, { maxLine = 64 * 1024 * 1024 } = {}) {
let start = 0;
for (let i = view.indexOf(0x0a); i !== -1; i = view.indexOf(0x0a, start)) {
if (skipping || partLen + (i - start) > maxLine) yield "";
else yield text(partLen ? Buffer.concat([...parts, view.subarray(start, i)]) : view.subarray(start, i));
else
yield text(
partLen ? Buffer.concat([...parts, view.subarray(start, i)]) : view.subarray(start, i)
);
parts = [];
partLen = 0;
skipping = false;
@@ -502,6 +513,7 @@ module.exports = {
assertOwnerFits,
hasLinuxAccount,
membersWithTmux,
mayHaveTmux,
memberHomes,
ownerForRead,
tmuxSocket,
+63 -4
View File
@@ -7,26 +7,85 @@
* it, and hands the "wait for the old PID to actually die, then spawn the
* replacement" job to a detached helper script — the dying process can't do
* that itself without racing its own replacement for the port.
*
* A server our service unit runs (systemd `agenthub.service`, system or user;
* the launchd job) is restarted BY that manager instead: a detached
* replacement would hold the port while `Restart=always` starts the unit
* again every few seconds, each start exiting "already running" — forever.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("fs");
const path = require("path");
const { spawn } = require("child_process");
const UNIT = "agenthub";
/**
* "systemd" when this process lives in our unit's cgroup, "launchd" when it
* is our LaunchAgent job, else null. Pure reads: no process is spawned.
*/
function owningUnit({
platform = process.platform,
cgroup = () => fs.readFileSync("/proc/self/cgroup", "utf8"),
env = process.env,
} = {}) {
if (platform === "darwin") {
return env.XPC_SERVICE_NAME === `io.smartgift.${UNIT}` ? "launchd" : null;
}
try {
return cgroup()
.split("\n")
.some((l) => l.trim().endsWith(`/${UNIT}.service`))
? "systemd"
: null;
} catch {
return null;
}
}
/**
* @param {string} [root] repo root the helper should run from.
* @returns {"service"|"exit"|"helper"} how the restart was handed over.
*/
function scheduleRestart(root = path.join(__dirname, "..", "..")) {
function scheduleRestart(root = path.join(__dirname, "..", ".."), deps = {}) {
const {
unit = owningUnit(),
svc = () => require("../../scripts/service-manager"),
kill = () => process.kill(process.pid, "SIGTERM"),
spawnHelper = spawn,
} = deps;
// systemd: one non-blocking restart job (sudo -n for the system unit). If
// that cannot be asked, a plain exit still comes back — the unit is
// Restart=always — but never with a second server beside it.
if (unit === "systemd") {
let restarted = false;
try {
restarted = svc().restartUserService(UNIT) === "restarted";
} catch {
/* fall through to the exit */
}
if (restarted) return "service";
kill();
return "exit";
}
// launchd: KeepAlive restarts the job on exit; a `bootout` from inside the
// job would wait for this very process.
if (unit === "launchd") {
kill();
return "exit";
}
const helperPath = path.join(root, "scripts", "restart-helper.js");
const entry = process.argv[1] || path.join(root, "server", "index.js");
const helper = spawn(process.execPath, [helperPath, String(process.pid), entry], {
const helper = spawnHelper(process.execPath, [helperPath, String(process.pid), entry], {
cwd: root,
detached: true,
stdio: "ignore",
env: process.env,
});
helper.unref();
process.kill(process.pid, "SIGTERM");
kill();
return "helper";
}
module.exports = { scheduleRestart };
module.exports = { scheduleRestart, owningUnit };
+7 -2
View File
@@ -226,16 +226,21 @@ function listOutput(acct = null) {
}
// A member's list is never waited for: a read answers from `listed` and,
// once that is LIST_TTL_MS old, starts one async re-list. Until a first
// once that is MEMBER_LIST_TTL_MS old, starts one async re-list. Until a first
// answer — or once the last is MEMBER_LIST_MAX_AGE_MS old (nobody polled) —
// or while their server fails, it throws: their panes are UNKNOWN. A session
// this process creates or kills is written into the answer at once (`noteSession`).
const MEMBER_LIST_MAX_AGE_MS = 15_000; // > the 10 s heal beat
// A member's list is a `sudo` (fork + PAM session + 3 auth.log lines) — at a
// 1 s TTL every open dashboard re-asked each member ~30 times a minute. A pane
// this process starts or ends is corrected at once (`noteSession`); only one
// that exits on its own reads live for up to these 5 s.
const MEMBER_LIST_TTL_MS = 5_000;
const refreshing = new Set(); // member logins with a list in flight
function memberListOutput(acct) {
const hit = listed.get(acct.login);
const age = hit ? Date.now() - hit.at : Infinity;
if (age >= LIST_TTL_MS) refreshList(acct);
if (age >= MEMBER_LIST_TTL_MS) refreshList(acct);
if (age >= MEMBER_LIST_MAX_AGE_MS) {
throw Object.assign(new Error(`${acct.login}: tmux list pending`), { code: "ETMUXPENDING" });
}
+48
View File
@@ -7,6 +7,7 @@
* - GET /api/users (Auth) — every account's usage (admin/analyst)
* - GET /api/users/audit (Auth) — staff reads of developers' sessions
* - GET/PUT /api/me/prefs, POST /api/me/onboarded (Auth) — the account's own UI preferences
* - GET/PUT /api/me/instructions (Auth) — the account's own agent instructions
* Exports `{ tags, schemas, paths }`, combined into the spec by
* server/openapi-extra.js. See docs/SERVER-MODE.md.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -90,6 +91,23 @@ const changeResponses = {
const prefsBody = {
"application/json": { schema: { $ref: "#/components/schemas/UserPrefs" } },
};
const instrBody = {
"application/json": {
schema: {
type: "object",
required: ["instructions", "max", "tools"],
properties: {
instructions: { type: "string" },
max: { type: "integer" },
tools: {
type: "array",
items: { type: "string" },
description: "AI tool ids whose new panes receive the instructions (claude, codex)",
},
},
},
},
};
const paths = {
"/api/me/prefs": {
@@ -127,6 +145,36 @@ const paths = {
},
},
},
"/api/me/instructions": {
get: {
tags: ["Auth"],
summary: "The account's own agent instructions",
description:
"Put before the workspace's own instructions in every new Claude/Codex pane of a workspace the account owns.",
responses: { 200: { description: "Instructions", content: instrBody }, 401: err("EAUTH") },
},
put: {
tags: ["Auth"],
summary: "Replace the account's agent instructions (blank or null clears)",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
properties: { instructions: { type: "string", nullable: true, maxLength: 16000 } },
},
},
},
},
responses: {
200: { description: "Saved", content: instrBody },
400: err("EBADFIELD — not a string"),
413: err("ETOOBIG — longer than 16 000 characters"),
401: err("EAUTH"),
},
},
},
"/api/me/onboarded": {
post: {
tags: ["Auth"],
+23
View File
@@ -123,8 +123,15 @@ function lastEventAge(lane) {
*/
function healRunId(lane, live = null) {
if (!lane.run_id) return lane;
// A member's pane on a server without S3 (a dev dashboard on the same DB):
// its tmux is not ours to ask, so "not listed" proves nothing.
if (require("../lib/linux-accounts").unseenMemberFolder(lane.cwd)) return lane;
if (live) {
if (live.has(lane.run_id)) return lane;
// Started a moment ago: a cached list may not show it yet (dashboard-runs.js).
const dashboardRuns = require("../lib/dashboard-runs");
const row = dashboardRuns.getRun(lane.run_id);
if (row && row.status === "running" && dashboardRuns.isYoungRun(row.started_at)) return lane;
} else {
const run = runs.getRun(lane.run_id);
if (run && run.status === "running") return lane;
@@ -2057,6 +2064,22 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
try {
switch (action) {
case "start": {
// A folder that is gone (or never was) would start the pane in the
// account's home instead, under this workspace's name. EACCES is not
// "gone": a member's folder may be unreadable to this process.
try {
if (!fs.statSync(lane.cwd).isDirectory())
throw Object.assign(new Error(), { code: "ENOTDIR" });
} catch (err) {
if (err.code === "ENOENT" || err.code === "ENOTDIR") {
return res.status(409).json({
error: {
code: "ENOCWD",
message: `the workspace folder does not exist: ${lane.cwd}`,
},
});
}
}
// `"auto"`: the first free tab, picked under the lane lock below — a
// client's own pick comes from a list that may already be stale.
const auto = body.slot === "auto";
+30
View File
@@ -8,6 +8,10 @@
* 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
* 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
* of a workspace the account owns (`launchExtras` in lib/lane-sources.js);
* `tools` names the AI tools whose panes receive them.
* `agenthub.chatUi` ("1") opens Claude/Codex sessions as a chat (`PaneChat`);
* the appearance keys are `useTheme`'s own (`theme`, `agenthub.theme.dark|light`).
*
@@ -105,6 +109,32 @@ router.put("/prefs", sameOriginGuard, (req, res) => {
res.json(read(key));
});
const sources = require("../lib/lane-sources");
const instructionsView = (key) => ({
instructions: sources.getUserInstructions(key) || "",
max: sources.MAX_INSTRUCTIONS,
// The tools a pane of which actually receives them (see launchExtras).
tools: [...sources.SUPPORTED_TOOLS],
});
router.get("/instructions", (req, res) => {
const key = userKey(req);
if (!key) return unauth(res);
res.json(instructionsView(key));
});
router.put("/instructions", sameOriginGuard, (req, res) => {
const key = userKey(req);
if (!key) return unauth(res);
try {
sources.setUserInstructions(key, req.body?.instructions ?? null);
} catch (err) {
const status = err.code === "ETOOBIG" ? 413 : 400; // as PUT /api/lanes/:id/instructions
return res.status(status).json({ error: { code: err.code, message: err.message } });
}
res.json(instructionsView(key));
});
router.post("/onboarded", sameOriginGuard, (req, res) => {
const key = userKey(req);
if (!key) return unauth(res);