diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index a75a7b5c..003ccd73 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -374,7 +374,7 @@ graph TD
| `lib/tmux.js` | Wrapper around tmux CLI for session management. `createSession(sessionName, cwd)` creates a new tmux session in the specified working directory. `sendCommand(sessionName, command)` sends a command into the session. `killSession(sessionName)` terminates the session. `listSessions()` returns all active sessions. Session management is the foundation for the PTY transport layer |
| `lib/pty-run.js` | PTY lifecycle for tmux-backed runs. Manages one tmux session per lane, named `agenthub-lane-`. Exports `startRun()` to create/attach a session and return a `runId` opaque handle; internally uses tmux to manage the pseudoterminal. Spawned `claude` processes run inside the session and fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, the analytics, the Kanban board, and the Workflows page automatically. The PTY frames (terminal input/output deltas) are streamed to the client over `/ws-pty/:runId` (see `server/websocket.js`) at binary frame granularity rather than as JSON envelopes; the client's xterm.js terminal widget renders these raw PTY updates live. Every spawn also mirrors itself into `dashboard_runs` (cwd, model, prompt preview, `resume_session_id`, lane id, start time) — that row is the ONLY source `publicRun` has for those fields, since the handle itself is recomputed from tmux on every read, so a dropped insert renders as a live run with no cwd, model or start date. `mode` is NOT NULL in that table and `recordRun` swallows insert errors by design, which is why this module passes a literal `"interactive"` rather than null. **Create-or-adopt has one refusal:** a pane already running an agent is adopted silently only when the request asked for nothing but a pane — a `resumeSessionId` or an initial prompt cannot be typed over a live agent, so it throws `ERUNLIVE` (409) rather than reporting a resume that never spawned |
| `lib/pty-attach.js` | Client-side PTY attachment via WebSocket. Establishes a `/ws-pty/:runId` connection, receives binary PTY frames, and feeds them to an xterm.js terminal instance. Handles reconnection, resize events (sending `TIOCSWINSZ` ioctl to the tmux pane), and cleanup on disconnect. A single tmux session can have many simultaneous PTY clients (browser Workspace, `agenthub lanes shell` CLI, etc.), all synced live The server side (`server/lib/pty-attach.js`) runs `tmux attach` with the LIVE tmux server's own binary (`/proc//exe`, else `tmux` on PATH): a tmux upgraded while its server runs leaves a newer client on PATH that the old server refuses with `open terminal failed: not a terminal`. |
-| `routes/run.js` | HTTP surface for the tmux+PTY Run feature. **Same-origin guard** on every route — browser requests must come from a localhost-ish Origin (`localhost`, `127.0.0.1`, `::1`, `0.0.0.0`); missing-Origin (curl/CLI) requests pass. When `DASHBOARD_TOKEN` is configured it is **also** required on these routes (same as the rest of `/api/*`). `GET /api/run` lists all live runs (computed fresh from tmux state via `tmux list-sessions`). `GET /api/run/tmux` reports whether `tmux` is installed and on PATH (required for the feature to work). `GET /api/run/binary` probes whether `claude` is on `PATH`. `GET /api/run/cwds` suggests cwds (dashboard + home + recent from sessions table). `GET /api/run/history?laneId=...` returns persisted run history, optionally scoped to one lane. `GET /api/run/files?cwd=&q=` powers the Workspace page's `@`-file autocomplete: scoped fuzzy search inside `cwd` skipping `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc., capped result count, ranked by basename match. `POST /api/run/drop-image` saves an image dropped on the terminal (raw body, four image types, random name under `/agenthub-drops-/`) and returns the path the terminal pastes. `POST /api/run` requires `laneId` in the body and starts/attaches a tmux-backed run. `GET /api/run/:id` returns the run handle. `DELETE /api/run/:id` kills the tmux session (sends SIGTERM to the pane, escalates to SIGKILL after 5 s). The PTY frames are streamed to the client over `/ws-pty/:runId` as binary frames (not JSON), rendering a real interactive terminal in xterm.js on the Workspace page. `tmux` must be installed on the dashboard server's machine (same as better-sqlite3's native-module requirements) |
+| `routes/run.js` | HTTP surface for the tmux+PTY Run feature. **Same-origin guard** on every route — browser requests must come from a localhost-ish Origin (`localhost`, `127.0.0.1`, `::1`, `0.0.0.0`); missing-Origin (curl/CLI) requests pass. When `DASHBOARD_TOKEN` is configured it is **also** required on these routes (same as the rest of `/api/*`). `GET /api/run` lists all live runs (computed fresh from tmux state via `tmux list-sessions`). `GET /api/run/tmux` reports whether `tmux` is installed and on PATH (required for the feature to work). `GET /api/run/binary` probes whether `claude` is on `PATH`. `GET /api/run/cwds` suggests cwds (dashboard + home + recent from sessions table). `GET /api/run/history?laneId=...` returns persisted run history, optionally scoped to one lane. `GET /api/run/files?cwd=&q=` powers the Workspace page's `@`-file autocomplete: scoped fuzzy search inside `cwd` skipping `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc., capped result count, ranked by basename match. `POST /api/run/drop-image` saves an image dropped on the terminal (raw body, four image types, random name under `/drops/` (a member's `~/.agenthub/handoff/drops/` under S3)) and returns the path the terminal pastes. `POST /api/run` requires `laneId` in the body and starts/attaches a tmux-backed run. `GET /api/run/:id` returns the run handle. `DELETE /api/run/:id` kills the tmux session (sends SIGTERM to the pane, escalates to SIGKILL after 5 s). The PTY frames are streamed to the client over `/ws-pty/:runId` as binary frames (not JSON), rendering a real interactive terminal in xterm.js on the Workspace page. `tmux` must be installed on the dashboard server's machine (same as better-sqlite3's native-module requirements) |
| `routes/lanes.js` | Durable-lane API. `POST /api/lanes/worktree`, `PATCH /api/lanes/:id`, destructive actions, and `DELETE /api/lanes/:id` use the Run route's same-origin guard. Worktree provisioning validates an absolute source git repository, persists a managed lane as `provisioning`, returns `202`, then uses the per-lane lock to resolve the base and add the worktree. Completion broadcasts the existing `lane_update` payload as `idle`; a git failure leaves a row that the non-destructive delete route can forget. `GET /api/lanes/:id/preflight?action=reset\|remove\|purge` produces counted confirmation facts. Confirmed `POST /:id/{reset,remove,purge}` actions require a complete `expect`, run under the same lock, kill a recorded run and wait for the spawner's actual child-exit timestamp (or return `500 ERUNTIMEOUT` before git), clear `run_id`, reject changed facts with `409 ESTALE` including expected/current diagnostics, and require `force` for unpushed managed reset/remove work. Reset and managed removal call the worktree's independent managed-kind, realpath-within-`LANES_ROOT`, and listed-worktree guard; adopted reset is refused, while adopted remove only forgets its row and never modifies its directory, and a managed lane whose directory was deleted by hand takes a prune path that still enforces the managed-kind and inside-`LANES_ROOT` checks. `start` returns `409 ERUNLIVE` rather than overwriting a live `run_id` and orphaning its child. `kind`, `source_repo`, `slug`, `base_branch`, `slot` and `ports` are not patchable — provisioning writes them through `lanesLib.setProvisioningFacts`. Worktree provisioning also runs `lane-runtime.js:provisionLane` (A2) when the repo declares a `.agenthub/profile` — seed `.env`, `bootstrap`, create the database, migrate, seed — before the lane reports `idle`; `reset`/`remove` likewise call `resetLaneData`/`removeLaneData`, with `reset` accepting a body `keepDb: true` to skip the whole drop-recreate-migrate-reseed block. The runtime routes (`GET /:id/runtime`, `POST /:id/up`, `POST /:id/down`, `POST /:id/hook/:name`, `GET /:id/logs/:svc`) are registered **before** the `/:id/:action` catch-all so `up`/`down` are not swallowed as unknown actions, and are deliberately kept out of it: that catch-all drives a lane's Claude run, these drive the application the lane is working on. `GET /api/lanes/pipeline-stats` aggregates per-node pass counts over every lane and every archived feature. The git surface (#34/#35) sits under `/:id/git/*`, again before the catch-all: `GET /:id/git/changes` (ranges `work`/`branch`/`commit`, with `context`), `/:id/git/log`, `/:id/git/file`, `/:id/git/ls`, `/:id/git/tree` and `/:id/git/branches` are read-only and unguarded like `/:id/git`, while `POST /:id/git/:action` is same-origin guarded and runs under the lane lock — and its per-file actions (`stage`, `unstage`, `discard`) accept only paths `git status` reports as changed, with `discard` additionally requiring `confirm: true`. |
| `lib/ports.js` | TCP probing for runtime allocation. `isListening(port)` connects rather than binds (binding races with the hook about to bind, and says nothing about a listener held by another user); a connect timeout counts as occupied. `listenerPids(port)` shells to `lsof`, falls back to `ss`, and returns `[]` with a one-time warning when neither exists — a missing tool must never fail a lane operation |
| `lib/lane-slots.js` | Slot and port allocation — the numbering Shipyard gets free from fixed `lane1..lane9` directories and AgentHub, keyed by `cwd`, must allocate. `allocateSlot` takes the lowest free of `LANE_MAX_SLOTS` (default 9) under the per-lane lock, with a partial unique index on `lanes.slot` as the backstop; allocation is **lazy**, so a lane that is only watched never consumes one. `releaseSlot` runs on remove but never on reset (moving a lane's ports mid-feature is a silent failure, not a fresh start). `resolvePorts` prefers `PORT_BASE_ + slot`, then steps `+100` at a time so the last digit still reads as the slot, skipping anything listening, recorded by another lane, or already taken in the same boot. `slotDirs` puts run/log state under `LANES_ROOT/.state/lane/` — outside the worktree, because `reset`'s `git clean -fd` would otherwise sweep live pid files. `dbName`/`dataFacts` (A2) derive the same kind of slot-based fact one layer up: database name, `DATABASE_URL`/`TEST_DATABASE_URL`, a Redis logical index, and the upload directory — each `null` when its owning profile declaration (`DB_PREFIX`, `REDIS`, `UPLOAD_SUBDIR`) is absent |
@@ -2244,6 +2244,7 @@ interface UpdateStatusPayload {
situation?: // categorical hint for the UI
| "plugin_managed" // ~/.claude/plugins/cache — /apply delegates
| "release_available" // fast-forward onto the release tag
+ | "diverged_from_release" // HEAD has commits the release lacks — fetch only, no apply
| "tracking_canonical"
| "fork_or_diverged_tracking"
| "feature_branch"
@@ -2309,6 +2310,7 @@ The same shape is used by `GET /status`, `POST /check`, and the `update_status`
| Plugin `/apply` after a failed release check | 409 `{reason:"plugin_managed", release_check:"unreachable"\|"no_local_version"\|...}` | No chain is spawned, and the message says the check did not succeed rather than claiming nothing newer exists — an unknown reported as a negative is how a broken check looks identical to an up-to-date install. |
| Healthy, behind, on canonical branch | `{update_available:true, situation:"tracking_canonical", manual_command:"...git pull --ff-only..."}` | Modal opens with `git pull` flow + restart hint. |
| Healthy, behind, fork (origin = fork, upstream = canonical) | `{update_available:true, situation:"fork_or_diverged_tracking", manual_command:"...git fetch upstream && git merge --ff-only upstream/master..."}` | Modal opens with merge flow + restart hint + `situation_note` explaining the divergence. |
+| Newer release, but HEAD holds commits the release lacks (e.g. local wip the release squashed) | `{update_available:true, situation:"diverged_from_release", manual_command:"...git fetch --tags"}` | Modal opens with no **Update now**; `situation_note` says how many commits diverge and offers `git reset --keep ` (when they are already in the release) or merge/rebase. `/apply` answers `not_fast_forwardable` instead of a raw `merge --ff-only` failure. |
| Healthy, behind, on a feature branch | `{update_available:true, situation:"feature_branch", manual_command:"...git fetch "}` | Modal opens; `situation_note` explains the user is off the canonical branch; restart hint suppressed because the working tree isn't being changed. |
### Why Self-Apply Stays Narrow
diff --git a/client/src/components/conversation/MarkdownContent.tsx b/client/src/components/conversation/MarkdownContent.tsx
index be6f0062..7604ad7a 100644
--- a/client/src/components/conversation/MarkdownContent.tsx
+++ b/client/src/components/conversation/MarkdownContent.tsx
@@ -4,7 +4,9 @@
* subset of CommonMark + GFM that actually appears in Claude Code transcripts:
* fenced code blocks, ATX headings, ordered/unordered lists, task lists, blockquotes,
* horizontal rules, simple tables, inline code, bold, italic, strikethrough, links,
- * and auto-linked URLs.
+ * 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.
*
* Output is built as a React element tree (no dangerouslySetInnerHTML) so user content
* is escaped by React. Code blocks delegate to for syntax highlighting and
@@ -244,6 +246,71 @@ function parseBlocks(src: string): Block[] {
return blocks;
}
+/** Opens a file a message names; provided by a view that can show one. */
+export const OpenFileContext = React.createContext<((path: string) => void) | undefined>(undefined);
+
+const LINK_CLASS =
+ "text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70 break-all";
+const URL_RE = /^https?:\/\/[^\s<>()]+[^\s<>().,!?;:'"]/;
+// Two segments at least, so "/" or "/s" stays text; a trailing full stop is prose.
+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]!);
+
+function UrlLink({ url }: { url: string }) {
+ return (
+
+ {url}
+
+ );
+}
+
+/** A path as a button that opens it, or as it was when nothing can. */
+function PathLink({ path, children }: { path: string; children: React.ReactNode }) {
+ const open = React.useContext(OpenFileContext);
+ if (!open) return <>{children}>;
+ return (
+
+ );
+}
+
+/** Plain text with its URLs and absolute paths as links — nothing else parsed. */
+export function linkify(text: string): React.ReactNode[] {
+ const out: React.ReactNode[] = [];
+ let buf = "";
+ for (let i = 0; i < text.length;) {
+ const rest = text.slice(i);
+ const url = atWordStart(text, i) && URL_RE.exec(rest)?.[0];
+ const path = !url && atWordStart(text, i) && PATH_RE.exec(rest)?.[0];
+ const hit = url || path;
+ if (!hit) {
+ buf += text[i++];
+ continue;
+ }
+ if (buf) out.push(buf);
+ buf = "";
+ out.push(
+ url ? (
+
+ ) : (
+
+ {hit}
+
+ )
+ );
+ i += hit.length;
+ }
+ if (buf) out.push(buf);
+ return out;
+}
+
/** Render inline markdown (bold/italic/code/strikethrough/links/auto-links). */
function renderInline(text: string, baseKey = ""): React.ReactNode[] {
const out: React.ReactNode[] = [];
@@ -267,11 +334,18 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
// Inline code: `...`
const codeM = rest.match(/^`([^`\n]+)`/);
if (codeM) {
- push(
+ const code = (
{codeM[1]}
);
+ push(
+ PATH_RE.exec(codeM[1]!)?.[0] === codeM[1] ? (
+ {code}
+ ) : (
+ code
+ )
+ );
i += codeM[0].length;
continue;
}
@@ -330,22 +404,21 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
}
// Auto-link
- const urlM = rest.match(/^https?:\/\/[^\s<>()]+[^\s<>().,!?;:'"]/);
+ const urlM = rest.match(URL_RE);
if (urlM) {
- push(
-
- {urlM[0]}
-
- );
+ push();
i += urlM[0].length;
continue;
}
+ // An absolute path at a word start
+ const pathM = atWordStart(text, i) ? PATH_RE.exec(rest) : null;
+ if (pathM) {
+ push({pathM[0]});
+ i += pathM[0].length;
+ continue;
+ }
+
buf += text[i]!;
i++;
}
diff --git a/client/src/components/conversation/MessageList.tsx b/client/src/components/conversation/MessageList.tsx
index 77ba1d8a..f70f3539 100644
--- a/client/src/components/conversation/MessageList.tsx
+++ b/client/src/components/conversation/MessageList.tsx
@@ -6,7 +6,10 @@
* MarkdownContent for prose. Used by ConversationView as the main body of
* the Conversation tab on the Session detail page. An image the prompt box
* attached reaches the transcript as its saved path; a user turn shows the
- * image instead (`GET /api/run/drop/:name`). Each assistant turn that changed
+ * image instead (`GET /api/run/drop/:name`), and Claude Code's own
+ * `[Image: source: ]` 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
* 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`). A thinking block that carries text is
@@ -124,7 +127,7 @@ const SENDER_STYLES: Record<
},
};
import { ToolCallBlock, buildSummary, renderInput, renderResult } from "./ToolCallBlock";
-import { MarkdownContent } from "./MarkdownContent";
+import { MarkdownContent, OpenFileContext, linkify } from "./MarkdownContent";
import { fmt, formatModelName, formatMs, parseDate } from "../../lib/format";
import { parseTuiSegments, stripAnsi, hasTuiTags, type TuiSegment } from "./tuiSegments";
@@ -440,7 +443,26 @@ export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[]
endTurn(key);
return items.push({ kind: "user", key, msg });
}
- if (sender !== "assistant") return items.push({ kind: "row", key, msg });
+ if (sender !== "assistant") {
+ // Claude Code's line for the image a prompt attached: the image joins that prompt.
+ const paths = sender === "system" ? imageSourcePaths(msg) : null;
+ const prompt = paths ? [...items].reverse().find((i) => i.kind === "user") : undefined;
+ if (paths && prompt?.kind === "user") {
+ prompt.msg = {
+ ...prompt.msg,
+ content: [
+ ...prompt.msg.content.map((c) =>
+ c.type === "text" && c.text
+ ? { ...c, text: c.text.replace(/\[Image #\d+\]/g, "") }
+ : c
+ ),
+ { type: "text", text: paths.join("\n") },
+ ],
+ };
+ return;
+ }
+ return items.push({ kind: "row", key, msg });
+ }
msg.content.forEach((block, b) => {
const last = items[items.length - 1];
if (block.type === "tool_use" && EDIT_TOOLS.has(block.name ?? "")) edits.push(block);
@@ -546,40 +568,44 @@ export function MessageList({ messages, loading, onOpenFile, live = false }: Mes
}
return (
-
- {items.map((item) => {
- switch (item.kind) {
- case "event":
- return (
-
- );
- case "row":
- return ;
- case "user":
- return ;
- case "text":
- return ;
- case "work":
- return ;
- case "edits":
- // The card closes a finished turn; one still working has none yet.
- if (pending && item === items[items.length - 1]) return null;
- return (
-
- );
- }
- })}
- {pending && }
-
+
+
+ {items.map((item) => {
+ switch (item.kind) {
+ case "event":
+ return (
+
+ );
+ case "row":
+ return ;
+ case "user":
+ return ;
+ case "text":
+ return ;
+ case "work":
+ return (
+
+ );
+ case "edits":
+ // The card closes a finished turn; one still working has none yet.
+ if (pending && item === items[items.length - 1]) return null;
+ return (
+
+ );
+ }
+ })}
+ {pending && }
+
+
);
}
@@ -646,18 +672,37 @@ function RunningStep({ block, since }: { block: TranscriptContent | null; since:
const meta = "text-[10px] font-mono text-fg-muted";
// What the prompt box sends for an attached image: its saved path under the
-// drop dir (`/agenthub-drops-/<16 hex>[-name].png`, server/routes/run.js).
+// drop dir (`/drops/<16 hex>[-name].png`, server/routes/run.js; the
+// older `/agenthub-drops-/` still in past transcripts).
const DROP_IMAGE =
- /(?<=^|\s)\S*[\\/]agenthub-drops--?\d+[\\/]([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.(?:png|jpe?g|gif|webp))(?=\s|$)/gi;
+ /(?<=^|\s)\S*[\\/](?:agenthub-drops--?\d+|drops)[\\/]([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.(?:png|jpe?g|gif|webp))(?=\s|$)/gi;
+// Claude Code's own line for an image it read off a pasted path (`isMeta`).
+const IMAGE_SOURCE = /^\[Image: source: (.+)\]$/;
-/** A user turn's text without its dropped-image paths, and those images' names. */
+/** A user turn's text without its dropped-image paths (and Claude Code's
+ * `[Image #N]` placeholders for them), and those images' names. */
export function splitDropImages(text: string): { text: string; images: string[] } {
const images: string[] = [];
const rest = text.replace(DROP_IMAGE, (_all, name: string) => {
images.push(name);
return "";
});
- return { text: images.length ? rest.replace(/[ \t]+$/gm, "").trim() : text, images };
+ return {
+ text: images.length
+ ? rest
+ .replace(/\[Image #\d+\]/g, "")
+ .replace(/[ \t]+$/gm, "")
+ .trim()
+ : text,
+ images,
+ };
+}
+
+/** The drop paths of a meta message made only of `[Image: source: ]` lines. */
+function imageSourcePaths(msg: TranscriptMessage): string[] | null {
+ const lines = msg.content.flatMap((c) => (c.type === "text" ? (c.text ?? "").split("\n") : []));
+ const paths = lines.map((l) => IMAGE_SOURCE.exec(l.trim())?.[1] ?? "");
+ return paths.length && paths.every((p) => splitDropImages(p).images.length) ? paths : null;
}
function DropImage({ name }: { name: string }) {
@@ -739,7 +784,7 @@ const UserBubble = memo(function UserBubble({ msg }: { msg: TranscriptMessage })
const plain = !hasTuiTags(text) && !isSkillContent(text) && !isTaskNotification(text);
return plain ? (
) : (
renderTextBlock(text, b)
diff --git a/client/src/components/conversation/__tests__/MessageList.images.test.tsx b/client/src/components/conversation/__tests__/MessageList.images.test.tsx
index 329f0dd0..5497c20d 100644
--- a/client/src/components/conversation/__tests__/MessageList.images.test.tsx
+++ b/client/src/components/conversation/__tests__/MessageList.images.test.tsx
@@ -3,7 +3,9 @@
* @description An image the prompt box attached reaches the transcript as its
* saved drop path; the user's turn shows the image (served by
* `GET /api/run/drop/:name`), opens it full size on the page, and keeps any
- * other path as typed.
+ * other path as typed. Claude Code turns a pasted drop path into an image
+ * block plus an `[Image: source: ]` meta line: the image joins the turn.
+ * URLs and absolute paths in a turn are links.
* @author Nguyễn Ngọc Trí Vĩ
*/
import { describe, it, expect } from "vitest";
@@ -22,6 +24,11 @@ describe("dropped images in a user turn", () => {
expect(splitDropImages("C:\\Temp\\agenthub-drops--1\\0123456789abcdef.webp").images).toEqual([
"0123456789abcdef.webp",
]);
+ // The drop dir since the data-dir move.
+ expect(splitDropImages(`[Image #1]fix this /home/me/.claude/agenthub/drops/${SHOT}`)).toEqual({
+ text: "fix this",
+ images: [SHOT],
+ });
// Not a drop, or not an image: left as typed.
for (const text of [
"/home/me/shot.png",
@@ -51,4 +58,56 @@ describe("dropped images in a user turn", () => {
fireEvent.click(img);
expect(screen.getByTestId("image-lightbox").parentElement).toBe(document.body);
});
+
+ it("folds Claude Code's [Image: source:] meta line into the turn", () => {
+ const messages = [
+ {
+ type: "user",
+ sender: "user",
+ timestamp: "2026-10-09T09:43:00.000Z",
+ content: [{ type: "text", text: "[Image #1]Hãy giúp tôi fix lỗi" }],
+ },
+ {
+ type: "user",
+ sender: "system",
+ timestamp: "2026-10-09T09:43:11.000Z",
+ content: [
+ { type: "text", text: `[Image: source: /home/me/.claude/agenthub/drops/${SHOT}]` },
+ ],
+ },
+ ] as TranscriptMessage[];
+ render();
+
+ expect(screen.queryByText("System")).toBeNull();
+ const bubble = screen.getByTestId("chat-user");
+ expect(bubble).toHaveTextContent("Hãy giúp tôi fix lỗi");
+ expect(bubble).not.toHaveTextContent("[Image #1]");
+ expect(screen.getByRole("img", { name: SHOT }).getAttribute("src")).toBe(
+ `/api/run/drop/${SHOT}`
+ );
+ });
+
+ it("makes URLs and absolute paths clickable", () => {
+ const opened: string[] = [];
+ const messages = [
+ {
+ type: "user",
+ sender: "user",
+ timestamp: "2026-10-09T09:43:00.000Z",
+ content: [
+ {
+ type: "text",
+ text: "xem (https://git.example.com/a/issues/69) và /home/me/repo/server/x.js.",
+ },
+ ],
+ },
+ ] as TranscriptMessage[];
+ render( opened.push(p)} />);
+
+ const link = screen.getByRole("link", { name: "https://git.example.com/a/issues/69" });
+ expect(link.getAttribute("href")).toBe("https://git.example.com/a/issues/69");
+ expect(link.getAttribute("target")).toBe("_blank");
+ fireEvent.click(screen.getByRole("button", { name: "/home/me/repo/server/x.js" }));
+ expect(opened).toEqual(["/home/me/repo/server/x.js"]);
+ });
});
diff --git a/client/src/components/lanes/WorkspaceHome.tsx b/client/src/components/lanes/WorkspaceHome.tsx
index 4f76c8f8..be47db72 100644
--- a/client/src/components/lanes/WorkspaceHome.tsx
+++ b/client/src/components/lanes/WorkspaceHome.tsx
@@ -119,6 +119,7 @@ function HomeBody({
onMenuAction,
onRenamed,
onSourcesChange,
+ sourcesVersion = 0,
}: {
lane: Lane;
/** Row-menu items the page carries out (its modals); rename is done here. */
@@ -127,6 +128,9 @@ function HomeBody({
onRenamed?: () => void;
/** Called with the sources after a folder or file is added or removed. */
onSourcesChange?: (view: LaneSourcesView) => void;
+ /** Bumped when the folder list changed elsewhere (the Changes panel's
+ * Close repository): the Folder list re-reads. */
+ sourcesVersion?: number;
}) {
const { t } = useTranslation("lanes");
const navigate = useNavigate();
@@ -314,6 +318,19 @@ function HomeBody({
};
}, [folderOpen, lane.id, prefs.showUntracked, prefs.hideSources, view?.sources]);
+ useEffect(() => {
+ if (!sourcesVersion) return;
+ let live = true;
+ api.lanes
+ .sources(lane.id)
+ .then((v) => live && setView(v))
+ .catch(() => undefined);
+ return () => {
+ live = false;
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- on a bump only
+ }, [sourcesVersion]);
+
const run = async (fn: () => Promise) => {
setError(null);
try {
diff --git a/client/src/components/run/ChatFilePreview.tsx b/client/src/components/run/ChatFilePreview.tsx
index c7e46930..549f8d17 100644
--- a/client/src/components/run/ChatFilePreview.tsx
+++ b/client/src/components/run/ChatFilePreview.tsx
@@ -31,7 +31,9 @@ export interface PreviewScope {
}
const IMAGE_RE = /\.(png|jpe?g|gif|webp)$/i;
-const DROP_RE = /\/agenthub-drops-\d+\/([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.[A-Za-z0-9]+)$/;
+// The drop dir (`/drops/`), or the older `/agenthub-drops-/`.
+const DROP_RE =
+ /\/(?:agenthub-drops-\d+|drops)\/([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.[A-Za-z0-9]+)$/;
export const baseName = (p: string) => p.split("/").pop() || p;
/** `report.v2.xlsx` → `XLSX`; none → "". */
diff --git a/client/src/i18n/locales/en/lanes.json b/client/src/i18n/locales/en/lanes.json
index 298a2bff..755693f8 100644
--- a/client/src/i18n/locales/en/lanes.json
+++ b/client/src/i18n/locales/en/lanes.json
@@ -48,6 +48,8 @@
"changes.bin": "bin",
"changes.binary": "Binary file — not shown.",
"changes.collapse": "Collapse the changes panel",
+ "changes.closeRepo": "Close repository",
+ "changes.closeOtherRepos": "Close other repositories",
"changes.collapseAll": "Collapse every file",
"changes.collapseAllFolders": "Collapse all folders",
"changes.collapseContext": "Show fewer surrounding lines",
@@ -295,7 +297,6 @@
"menu.remove": "Delete workspace…",
"panel.changes": "Changes",
"panel.analyst": "Analyst",
- "panel.folder": "Folder to show changes for",
"replay.placeholder": "Message to continue this session…",
"replay.hint": "Sending resumes the session in a free pane.",
"replay.send": "Send",
diff --git a/client/src/i18n/locales/vi/lanes.json b/client/src/i18n/locales/vi/lanes.json
index 31fe1e7e..2db6ede4 100644
--- a/client/src/i18n/locales/vi/lanes.json
+++ b/client/src/i18n/locales/vi/lanes.json
@@ -48,6 +48,8 @@
"changes.bin": "bin",
"changes.binary": "File nhị phân — không hiển thị.",
"changes.collapse": "Thu gọn bảng thay đổi",
+ "changes.closeRepo": "Đóng repository",
+ "changes.closeOtherRepos": "Đóng các repository khác",
"changes.collapseAll": "Thu gọn tất cả file",
"changes.collapseAllFolders": "Thu gọn tất cả thư mục",
"changes.collapseContext": "Bớt dòng xung quanh",
@@ -295,7 +297,6 @@
"menu.remove": "Xóa workspace…",
"panel.changes": "Thay đổi",
"panel.analyst": "Analyst",
- "panel.folder": "Thư mục để xem thay đổi",
"replay.placeholder": "Nhắn để tiếp tục session này…",
"replay.hint": "Gửi tin sẽ resume session trong một pane trống.",
"replay.send": "Gửi",
diff --git a/client/src/lib/types.ts b/client/src/lib/types.ts
index e1fd8995..0fe395bb 100644
--- a/client/src/lib/types.ts
+++ b/client/src/lib/types.ts
@@ -1344,6 +1344,7 @@ export interface UpdateStatusPayload {
situation?:
| "plugin_managed"
| "release_available"
+ | "diverged_from_release"
| "tracking_canonical"
| "fork_or_diverged_tracking"
| "feature_branch"
diff --git a/client/src/pages/Workspace.tsx b/client/src/pages/Workspace.tsx
index ab31769b..00d189b6 100644
--- a/client/src/pages/Workspace.tsx
+++ b/client/src/pages/Workspace.tsx
@@ -55,7 +55,15 @@ import { runWorktreeSession, type RunWorktree } from "../lib/runWorktree";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
-import { PanelLeftClose, PanelRightClose, Maximize2, Minimize2, ChartColumn } from "lucide-react";
+import {
+ PanelLeftClose,
+ PanelRightClose,
+ Maximize2,
+ Minimize2,
+ ChartColumn,
+ ChevronDown,
+ ChevronRight,
+} from "lucide-react";
import { api } from "../lib/api";
import type {
CwdSuggestion,
@@ -66,6 +74,7 @@ import type {
} from "../lib/api";
import type { Lane, LaneFeature, ProofFeature, Session } from "../lib/types";
import { toast } from "../lib/toast";
+import { ContextMenu, MenuItem, useContextMenu } from "../components/ContextMenu";
import { confirmDialog, confirmDiscard } from "../lib/confirm";
import { keepIfSame } from "../lib/keepIfSame";
import { useWorkLanes } from "../hooks/useLanes";
@@ -709,9 +718,27 @@ export function Workspace() {
? sessionParam
: undefined;
- // The workspace's extra folders: each is its own repo the Changes panel can
- // show instead (`?source=`), picked in its header. Read per workspace, and
- // again from the home's Folder list whenever it adds or removes one.
+ // An unsaved whole-file edit in a Changes panel section, by section key. While
+ // one is open the panel stays on the tree it was opened in — moving it
+ // (another workspace, session or worktree) would remount it and drop the
+ // edit — until the user says to discard it, or saves.
+ const [dirtyBy, setDirtyBy] = useState>({});
+ const changesDirty = Object.values(dirtyBy).some(Boolean);
+ const dirtyFor = useCallback(
+ (key: string) => (dirty: boolean) => setDirtyBy((d) => ({ ...d, [key]: dirty })),
+ []
+ );
+ const wantPanel =
+ selectedLaneId === null ? null : { laneId: selectedLaneId, session: gitSession };
+ const keyOf = (p: typeof wantPanel) => (p ? `${p.laneId}:${p.session ?? ""}` : "");
+ const wantPanelKey = keyOf(wantPanel);
+ const [panelAt, setPanelAt] = useState(wantPanel);
+ const panelKey = keyOf(panelAt);
+
+ // The workspace's extra folders: each is its own repo, stacked under the
+ // workspace folder's in the Changes panel (`?source=`). Read for the
+ // workspace the panel shows, and again from the home's Folder list whenever
+ // it adds or removes one.
const [gitFolders, setGitFolders] = useState<{ laneId: number; items: LaneSource[] } | null>(
null
);
@@ -723,35 +750,37 @@ export function Workspace() {
}),
[]
);
+ const panelLaneId = panelAt?.laneId ?? null;
useEffect(() => {
- if (selectedLaneId === null) return;
- api.lanes.sources(selectedLaneId).then(
- (v) => takeGitFolders(selectedLaneId, v),
- () => setGitFolders({ laneId: selectedLaneId, items: [] })
+ if (panelLaneId === null) return;
+ api.lanes.sources(panelLaneId).then(
+ (v) => takeGitFolders(panelLaneId, v),
+ () => setGitFolders({ laneId: panelLaneId, items: [] })
);
- }, [selectedLaneId, takeGitFolders]);
- const folders = gitFolders?.laneId === selectedLaneId ? gitFolders.items : [];
- const [gitSourcePick, setGitSourcePick] = useState<{ laneId: number; id: number } | null>(null);
- // A pick holds for its own workspace, while that folder is still listed.
- const gitSource =
- gitSourcePick?.laneId === selectedLaneId && folders.some((f) => f.id === gitSourcePick.id)
- ? gitSourcePick.id
- : undefined;
-
- // An unsaved whole-file edit in the Changes panel. While it is open the
- // panel stays on the tree it was opened in — moving it (another workspace,
- // session, worktree or folder) would remount it and drop the edit — until
- // the user says to discard it, or saves.
- const [changesDirty, setChangesDirty] = useState(false);
- const wantPanel =
- selectedLaneId === null
- ? null
- : { laneId: selectedLaneId, session: gitSession, source: gitSource };
- const keyOf = (p: typeof wantPanel) =>
- p ? `${p.laneId}:${p.session ?? ""}:${p.source ?? ""}` : "";
- const wantPanelKey = keyOf(wantPanel);
- const [panelAt, setPanelAt] = useState(wantPanel);
- const panelKey = keyOf(panelAt);
+ }, [panelLaneId, takeGitFolders]);
+ const folders = gitFolders?.laneId === panelLaneId ? gitFolders.items : [];
+ // Sections folded shut ("" = the workspace folder, else the folder's id).
+ const [foldedSources, setFoldedSources] = useState>(new Set());
+ // Right-click on a section: close extra folders — which REMOVES them from the
+ // workspace (the folder stays on disk). The workspace folder never closes.
+ const sourceCtx = useContextMenu();
+ // Bumped after a close here, so the home's Folder list re-reads.
+ const [sourcesVersion, setSourcesVersion] = useState(0);
+ const closeSources = async (laneId: number, keys: string[]) => {
+ if (keys.some((k) => dirtyBy[k]) && !(await confirmDiscard())) return;
+ try {
+ let view: LaneSourcesView | null = null;
+ for (const k of keys) view = await api.lanes.removeSource(laneId, Number(k));
+ if (view) takeGitFolders(laneId, view);
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : String(err));
+ api.lanes.sources(laneId).then(
+ (v) => takeGitFolders(laneId, v),
+ () => undefined
+ );
+ }
+ setSourcesVersion((n) => n + 1);
+ };
const asking = useRef(false);
useEffect(() => {
if (wantPanelKey === panelKey) return;
@@ -764,7 +793,7 @@ export function Workspace() {
void confirmDiscard().then((ok) => {
asking.current = false;
if (!ok) return;
- setChangesDirty(false);
+ setDirtyBy({});
setPanelAt(wantPanel);
});
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the keys
@@ -780,8 +809,12 @@ export function Workspace() {
const onOpen = (e: Event) => {
const { laneId, path } = (e as CustomEvent).detail;
if (laneId !== panelLaneRef.current) return;
- // Its path is the workspace folder's: back from an extra folder first.
- setGitSourcePick(null);
+ // Its path is the workspace folder's: unfold that section.
+ setFoldedSources((prev) => {
+ const next = new Set(prev);
+ next.delete("");
+ return next;
+ });
setChangesOpen(true);
setChangesRequest((r) => ({ path, nonce: (r?.nonce ?? 0) + 1 }));
};
@@ -792,10 +825,7 @@ export function Workspace() {
useEffect(() => {
if (!changesOpen) setChangesRequest(null);
}, [changesOpen]);
- // Not on a folder pick: the request above makes one, to land in the panel
- // it remounts; the picker drops the request itself.
- const panelTreeKey = panelAt ? `${panelAt.laneId}:${panelAt.session ?? ""}` : "";
- useEffect(() => setChangesRequest(null), [panelTreeKey]);
+ useEffect(() => setChangesRequest(null), [panelKey]);
/** Runs `fn` once an unsaved edit (if any) may be dropped. */
const afterDrop = (fn: () => void) => {
if (!changesDirty) return fn();
@@ -1036,6 +1066,7 @@ export function Workspace() {
lane={currentLane}
onRenamed={() => void refreshLanes()}
onSourcesChange={(v) => takeGitFolders(currentLane.id, v)}
+ sourcesVersion={sourcesVersion}
onMenuAction={(action) => {
if (action === "new-here") {
setAddLanePrefill({ sourceRepo: currentLane.source_repo || currentLane.cwd });
@@ -1268,34 +1299,6 @@ export function Workspace() {
{tLanes("panel.changes")}
- {/* Which repo the panel shows: the workspace's folder or
- one of its extra folders. Only when it has any. */}
- {folders.length > 0 && (
-
- )}
{/* The opened session's analysis page — a page of its own. */}
- {panelAt && (
- setChangesOpen(true)}
- />
- )}
+ {panelAt &&
+ [undefined, ...folders.map((f) => f.id)].map((source) => (
+ setChangesOpen(true)}
+ />
+ ))}
))}
diff --git a/client/src/pages/__tests__/Workspace.changesPanel.test.tsx b/client/src/pages/__tests__/Workspace.changesPanel.test.tsx
index 05502360..8a1571bc 100644
--- a/client/src/pages/__tests__/Workspace.changesPanel.test.tsx
+++ b/client/src/pages/__tests__/Workspace.changesPanel.test.tsx
@@ -95,6 +95,10 @@ vi.mock("../../lib/api", async (importOriginal) => {
: [],
})
),
+ // Closing a folder's section removes it from the workspace.
+ removeSource: r({
+ sources: [{ id: 8, kind: "file", path: "/d/notes.md", name: "notes.md", missing: false }],
+ }),
gitTree: r({ entries: [] }),
// A file opened in the panel on a chat's request.
changes: r({ available: false }),
@@ -244,32 +248,50 @@ describe("Workspace — the Changes panel's width", () => {
expect(api.lanes.gitFile).toHaveBeenCalledWith(1, "src/a.ts", "work");
});
- it("shows an extra folder's repo when picked, and only a workspace's own folders", async () => {
- await openPanel();
- const pick = screen.getByTestId("panel-git-folder") as HTMLSelectElement;
- // The workspace folder, then its folders — never a context file.
- expect([...pick.options].map((o) => o.text)).toEqual(["alpha", "shop"]);
+ it("stacks every extra folder's repo under the workspace folder's, and only its own folders", async () => {
vi.mocked(api.lanes.git).mockClear();
- fireEvent.change(pick, { target: { value: "7" } });
- await settle();
+ await openPanel();
+ const sections = screen.getAllByTestId("panel-git-source");
+ // The workspace folder, then its folders — never a context file.
+ expect(sections.map((s) => s.querySelector("button")!.textContent)).toEqual(["alpha", "shop"]);
+ expect(api.lanes.git).toHaveBeenCalledWith(1);
expect(api.lanes.git).toHaveBeenCalledWith({ lane: 1, source: 7 });
- // A chat's file is the workspace folder's: the panel goes back there.
+ // A folded section keeps its panel mounted, only hidden.
+ const toggle = sections[0]!.querySelector("button")!;
+ fireEvent.click(toggle);
+ expect(toggle).toHaveAttribute("aria-expanded", "false");
+ expect(sections[0]!.querySelector('[data-testid="lane-changes"]')).not.toBeNull();
+ // A chat's file is the workspace folder's: its section unfolds.
act(() => {
openInChanges(1, "/workspace/alpha", "/workspace/alpha/src/b.ts");
});
await settle();
- expect(pick.value).toBe("");
+ expect(toggle).toHaveAttribute("aria-expanded", "true");
expect(api.lanes.gitFile).toHaveBeenCalledWith(1, "src/b.ts", "work");
});
- it("has no folder picker for a workspace with no extra folders", async () => {
+ it("closes an extra folder from its right-click menu, removing it from the workspace — never the workspace folder", async () => {
+ await openPanel();
+ const [main, shop] = screen.getAllByTestId("panel-git-source");
+ fireEvent.contextMenu(main!.querySelector("button")!);
+ expect(screen.queryByTestId("panel-git-source-close")).toBeNull();
+ fireEvent.keyDown(document, { key: "Escape" });
+ fireEvent.contextMenu(shop!.querySelector("button")!);
+ fireEvent.click(screen.getByTestId("panel-git-source-close"));
+ await settle();
+ expect(api.lanes.removeSource).toHaveBeenCalledWith(1, 7);
+ // The last folder gone: back to the one plain panel.
+ expect(screen.queryByTestId("panel-git-source")).toBeNull();
+ });
+
+ it("is one plain panel for a workspace with no extra folders", async () => {
render(
);
await settle();
- expect(screen.queryByTestId("panel-git-folder")).toBeNull();
+ expect(screen.queryByTestId("panel-git-source")).toBeNull();
});
it("collapses from its narrow width too, back to the rail", async () => {
diff --git a/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap b/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap
index bfcae4ea..a0177a9a 100644
--- a/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap
+++ b/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap
@@ -7174,7 +7174,7 @@ exports[`screen snapshots > Settings — about 1`] = `
- UI build v2.6.0 (rebuild client to match)
+ UI build v2.6.1 (rebuild client to match)
diff --git a/docs/API.md b/docs/API.md
index dcc3409d..667edb26 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -698,7 +698,7 @@ otherwise `400 ENOWORKTREE`, never a fall back to the main folder. With it,
Every `/api/lanes/:id/git…` route also takes `?source=`: the read or write
then works in one of the workspace's extra folders (Context → Folder), each
-its own repo — the Changes panel's folder picker. The tree is resolved from the
+its own repo — a section of its own in the Changes panel. The tree is resolved from the
`lane_sources` ROW (`sourceFolder` in `server/lib/lane-sources.js`): a `folder`
row of THIS workspace that is still a directory — otherwise `404 ENOSOURCE`
(another workspace's row and a context-file row included), never a fall back
@@ -1571,7 +1571,7 @@ curl http://localhost:4820/api/sessions/sess_abc123
GET /api/sessions/:id/outline
```
-The session's plan, the files it changed and what it used, read off its successful tool calls (`PostToolUse` events) and its prompts (`UserPromptSubmit`) — the activity panel of a Workspace Chat pane shown alone. `progress` follows `TaskCreate`/`TaskUpdate` by task id (a `TodoWrite` replaces the list wholesale); `outputs` are the paths written or edited with `Write`/`Edit`/`MultiEdit`/`NotebookEdit`; `files` the paths it only `Read` (a file it later changed moves to `outputs`); `uploads` the files attached to its prompts through the composer (paths under the `agenthub-drops-` dir that `POST /api/run/drop-image|drop-file` made); `memory` the Claude Code memory files (`.claude/**/memory/`) it read or wrote, kept out of the other lists. The path lists are newest first. `skills` are `Skill` calls; `mcp` are MCP server names (`mcp____`, a `plugin_` prefix dropped). A file made by a Bash command is not an output here. 404 for an unknown session.
+The session's plan, the files it changed and what it used, read off its successful tool calls (`PostToolUse` events) and its prompts (`UserPromptSubmit`) — the activity panel of a Workspace Chat pane shown alone. `progress` follows `TaskCreate`/`TaskUpdate` by task id (a `TodoWrite` replaces the list wholesale); `outputs` are the paths written or edited with `Write`/`Edit`/`MultiEdit`/`NotebookEdit`; `files` the paths it only `Read` (a file it later changed moves to `outputs`); `uploads` the files attached to its prompts through the composer (paths under the `/drops/` dir — `agenthub-drops-` in older transcripts — that `POST /api/run/drop-image|drop-file` made); `memory` the Claude Code memory files (`.claude/**/memory/`) it read or wrote, kept out of the other lists. The path lists are newest first. `skills` are `Skill` calls; `mcp` are MCP server names (`mcp____`, a `plugin_` prefix dropped). A file made by a Bash command is not an output here. 404 for an unknown session.
```bash
curl http://localhost:4820/api/sessions/sess_abc123/outline
@@ -1582,7 +1582,7 @@ curl http://localhost:4820/api/sessions/sess_abc123/outline
"progress": [{ "id": "1", "subject": "Build the report", "status": "completed" }],
"outputs": ["/home/me/work/Progress report.xlsx"],
"files": ["/home/me/work/data/kpi.csv"],
- "uploads": ["/tmp/agenthub-drops-1000/3f9a0c1d2e4b5a6c-minutes.xlsx"],
+ "uploads": ["/home/me/.claude/agenthub/drops/3f9a0c1d2e4b5a6c-minutes.xlsx"],
"memory": ["/home/me/.claude/projects/-home-me-work/memory/launch.md"],
"skills": ["xlsx"],
"mcp": ["vercel_vercel"]
diff --git a/docs/LANES.md b/docs/LANES.md
index 0f54e753..2f9d3e1e 100644
--- a/docs/LANES.md
+++ b/docs/LANES.md
@@ -395,7 +395,7 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page
- **Row menu** — right-click a row, or its `⋯` button: Rename, the Model (AI Proxy) select, New workspace here…, Copy path, Reset worktree… (managed only) and Delete workspace…, each the call the rest of the UI already makes. Delete is record-only exactly as before (see "Removing a workspace").
- **Right-click menus** — every item that has actions opens them on a right-click too, in one shell (`client/src/components/ContextMenu.tsx`: placed at the pointer, kept inside the viewport, closed by Escape, a click outside or an item). The buttons stay; the menu repeats what they do and nothing more. A session row — in the sidebar, the home's Recents and the archived fold (`lanes/SessionMenu.tsx`) — offers Open, Open in split (live), Rename and Archive (sidebar), Unarchive (archived), Session details, Copy session ID and Close tab (live); the sidebar's **New session** row Open, Open in split and Close tab. A console tab offers Open, Open in split, Session details, Copy session ID and Close tab; a pane header (split view) Show here, Split right/down with a new terminal and Close pane. In the Changes panel a changed file (overview row or review header) offers Open, Stage/Unstage, Discard — two clicks, as on its trash button — or the conflict's Keep mine / Take theirs / Mark resolved, and Copy relative path; a Files tree entry Open and Copy relative path; a History commit View commit, Copy commit SHA and Copy commit message. The home's title opens the row menu, and a Context file or extra folder offers Copy path and Remove. Outside the workspace the same shell serves the Sessions table, the Dashboard's agent cards and recent events, the Activity feed and Workflow runs (`components/SessionRowMenu.tsx`: Open session details, Open in its workspace when it has one, Copy session ID, Copy folder path), an attached prompt image (View / Remove), the managed workspace card (Reset), every Settings list (Git credentials, AI Proxy, Pricing, alert rules, webhooks, remote sources, AI tools) and every Claude Code / Codex config list (skills, agents, commands, output styles and memory files via `FileItemMenu` in `components/config/MdItemList.tsx`; plugins, MCP servers, marketplaces, auth profiles). A delete or remove there takes the button's own confirm — a modal, or two clicks. Text areas (the terminal, a conversation, a diff) keep the browser's own menu. The shell stops the menu's clicks from reaching the card it was opened from.
- **Chat view of a live pane** — each Claude or Codex SESSION shows either a chat or its terminal, its own choice: the chat/terminal button left of the pane's split buttons (`ChatToggle` in `client/src/components/run/PaneGrid.tsx`) switches it, and the choice is kept in this browser's localStorage by session id (`agenthub.chatUi.sessions`, the newest 200; `useSessionChat` in `client/src/lib/chatUi.ts`). **Settings → Appearance → Chat UI** (`ChatUiSettings.tsx`, saved per ACCOUNT on the server: `GET/PUT /api/me/prefs`, key `agenthub.chatUi`) is only the default — written onto a session the first time it is shown, so changing it later leaves every session already seen as it is. The chat needs the session id, so a pane shows it only once its session is known (`client/src/components/run/LaneConsolePane.tsx`). Chat (`client/src/components/run/PaneChat.tsx`) shows the session's conversation the way Claude Desktop does — the shared `ConversationView` in its `fill` layout: one centred column, your messages as bubbles, the reply as plain prose, every run of tool calls folded into one line named by what it did, in Claude Desktop's fixed order ("Ran 3 commands, edited a file, used a tool ›", `workLabel`) — a thinking block that carries text is the model's narration (Claude Code ≥ 2.1.280 stores real thinking empty) and reads as prose between those lines, as Claude Desktop shows it — that opens onto a framed list of its steps — each titled by its own description, the call in a capped scroll box, its output one click further — and a round ↓ back to the end — above a prompt box that grows with its text up to 40% of the screen, then scrolls. The conversation stays at its end while you are there: a new line, the running step, an image loading or the pane shown again after being hidden keeps the newest in sight (a `ResizeObserver` in `ConversationView`), until you scroll up; sending a message takes it back to the end. It lies OVER the terminal, which stays mounted and attached (the console never unmounts a terminal); a message is sent through that terminal as a bracketed paste and Enter (`submitToRun` in `client/src/lib/panePaste.ts`), exactly as if typed, so no new route or server write exists for it. While the session's main agent is `working` (its `UserPromptSubmit` hook seen, no `Stop` yet — `GET /api/agents?session_id=&status=working`, re-read on `agent_updated`), an empty box shows **Stop** in Send's place, like Claude Desktop: it presses Esc in the pane (`interruptRun` in the same file), the key `claude` and `codex` stop a turn with; typing a message brings Send back (Claude Code queues it). A Codex pane without AgentHub's hooks never shows it. An image attached in the box reaches the transcript as its saved path; the user's turn shows the image itself (`GET /api/run/drop/:name`) and opens it full size over the whole page (`ImageLightbox`, rendered into `document.body`, so nothing stacked over a pane covers it). The box carries Claude Desktop's controls: a `+` menu (`ChatAddMenu.tsx`, also in the workspace home's prompt box) — Upload or add files; what already exists, attached like an upload (a chip, its path sent with the prompt): **Project files** (a search of the box's folder, folders too — `GET /api/run/files?dirs=1` — sent as `@path`), **Context files** (the workspace's own, `GET /api/lanes/:id/sources`, sent as `@`) and **Recent uploads** (files uploaded through a prompt box in the last day, kept in this browser's localStorage `agenthub.prompt.recentUploads` — the server prunes an upload after a day); a file or folder dragged from the Changes panel's Files tree or search onto any prompt box attaches the same way (`client/src/lib/promptAttach.ts`); Skills (yours and the project's), Plugins → a plugin → the skills and commands it ships, each put at the front of the message as `/` / `/:`, and Manage links to the Claude config page — and, for a Claude pane, the Model/Effort chip, a change typed into the pane as `/model ` / `/effort `, and the permission mode beside `+` in the box. A menu opened in a split pane (a `Select`, the Model chip) flips up or down by the room inside ITS pane (`data-menu-bounds` on the pane, `menuRoom` in `client/src/components/Select.tsx`), never the window's — a menu running past the pane's edge is painted over by the pane below. A running session changes mode only by Shift+Tab, so the server steps it and reads each step back off the pane (`POST /api/run/:id/permission-mode`, `server/lib/pane-mode.js`): it lands on exactly the mode picked, presses nothing while a menu hides the mode line, and returns to where it began when the mode is not in that session's cycle (Bypass only when the session was started with it). The mode belongs to that session only. A permission prompt or a menu still needs the terminal — switch that session to its terminal to answer it. A live pane's chat ends, until its turn is over, with what the model is doing now and for how long (`workingOn` in `MessageList.tsx`): the call still waiting for its result — its description (Claude Code's Bash `description`) or tool and target — else **Thinking…** (after your prompt, after a tool result, or after narration whose `stop_reason` is `tool_use`). The transcript route passes each assistant line's `stop_reason`; `end_turn` ends the turn, so does an interrupt, and a turn with no new line for 15 minutes and no call running is treated as dead. The chat re-reads the transcript every 3 s while the page is visible. Each message's hover line shows ↓ the whole prompt it was answered from (input + cache read + cache write; the split on hover) and ↑ its output. Each assistant turn that changed files ends, like Claude Desktop's, with an **Edited N files** card (`EditedFiles` in `client/src/components/conversation/MessageList.tsx`): one row per file with the lines it added and removed over the turn — the `diff` the transcript route reads off Claude Code's `structuredPatch` — and, in a live pane's chat, a click on a row opens that file in the workspace's Changes panel when it lies in the pane's folder, else its preview (beside the chat, or over it when the pane is narrow). It has no Undo: reverting a file is a write the dashboard does not take from a click. The conversation column and the prompt box are `max(48rem, 70%)` of the pane wide — never narrower than Claude's fixed 48rem, wider on a large screen. While the pane is the only one shown (no split) and the window is wide (`lg`), it has Claude Desktop's activity panel (`client/src/components/run/ChatOutline.tsx`, `GET /api/sessions/:id/outline`, re-read every 5 s while the tab is visible). It starts collapsed into a file button with the number of outputs at the chat's top right; opening or closing it is remembered per browser (`localStorage` `agenthub.chat.outlineOpen`). Open, it shows **Progress** — the session's `TaskCreate`/`TaskUpdate` (or `TodoWrite`) plan, done items struck through, only when there is one; **Outputs** — files it wrote or edited (`Write`/`Edit`/`MultiEdit`/`NotebookEdit`), newest first, each with its extension tag (a file made by a Bash command is not listed); and **Used in this session** — **Uploads** (files attached through the box), **Files read**, **Memory** (Claude Code memory files), **Skills** and **Connectors** (MCP servers). A click on an output opens its preview in the panel's place (`FilePreview` in `client/src/components/run/ChatFilePreview.tsx`: name · EXT, Copy path, Close back to the panel); Uploads, Files read and Memory open their list with a preview beside it (`FileListModal`); Skills and Connectors open the Claude config page. A preview reads only through routes that already guard it — a file under the pane's folder through `GET /api/lanes/:id/git/file` (an image through `/git/raw`), a memory file under `~/.claude` through `GET /api/cc-config/file`, an attached image through `GET /api/run/drop/:name` — and shows just the path for anything else (an attached spreadsheet, a file outside the workspace, one since deleted). The ended-session replay and the Analyst page's conversation use the same layout.
-- **The workspace home** — `/workspace?lane=` opens on the workspace's home (`client/src/components/lanes/WorkspaceHome.tsx`), shaped like a Claude Desktop project page: the name (rename from its `⋯` menu, which is the sidebar row menu) with the repository it belongs to under it (root, the main worktree's branch, the worktree count, from `GET /api/projects` — the facts Settings → Projects used to show), a prompt box — the one place a session starts WITH a prompt (a pane has no run form; the session view's tab bar `+` only starts a bare CLI or a shell) — whose fields run in order **AI** (the session's own tool: every installed, enabled one, the workspace's by default), **AI Proxy** (Native or a saved profile; Native alone, disabled, for a tool that cannot use one), **Model** (the tool's own list, or on a router that router's models), **Permission** (Claude's permission mode; Codex's sandbox and approval; a catalog tool's per-session bypass switch) and **Effort** (Claude and Codex), whose half-typed text is kept per workspace in `agenthub.workspace.draft.` (localStorage, so it survives leaving the page or switching workspace; cleared once sent — attached images are not kept), and whose **Start** opens a NEW session in the first slot with no live run (0–9, `POST /api/lanes/:id/start {slot}`; all ten busy is refused) and then shows it in a tab of its own, and **Recents** — the workspace's own sessions (`?lane=`) with their cost and time, a live one opening its pane and any other its conversation (`&session=`), exactly like the sidebar. The transcript page (`/sessions/`) of a Claude session started in a workspace's own folder carries an explicit **Resume** (`client/src/components/lanes/SessionResumeButton.tsx`, the one place that resumes) and beside it **Fork**, which resumes the history into a NEW session id (`{forkSession: true}` → `--fork-session`, the original untouched, offered even while the original has a live pane): it warns that a resume re-reads the whole transcript into context, re-reads the live panes, then starts it in the workspace's first free pane (`POST /api/lanes/:id/start {slot, resumeSessionId}`) and opens it. A session from a SUBFOLDER gets no Resume — the pane starts in the workspace's folder and Claude finds a session by the folder it ran in — and a session that still has a live pane shows **Open pane** instead. On the right sit the workspace's own context: **Instructions** (appended to every new session's system prompt), **Memory** (read-only: the folder's CLAUDE.md / AGENTS.md family and Claude's auto memory), **Context** (uploaded files, ≤ 10 MB each, stored by AgentHub) and **Folder** (the workspace's own folder plus extra ones, with the repo's untracked worktrees suggested). Only Claude and Codex panes can take them (`--add-dir`, `--append-system-prompt-file=` / `-c developer_instructions=`); for any other tool the page says so and its panes start without them. See `docs/API.md` → Workspace context. Each extra folder is also its own repo in the **Changes** panel: a folder picker in its header (shown once the workspace has one) switches the whole panel — Changes, Files, Review, History, commit and push — to that folder (every git route with `?source=`, resolved from the `lane_sources` row; `ENOSOURCE` otherwise, never the workspace folder). A chat's request to open a file switches it back to the workspace folder. A plain-text paste over 5,000 characters into any prompt box (the home, a chat, a finished session's reply) is attached as `pasted-text.txt` like a pasted file (`PASTE_AS_FILE_CHARS` in `client/src/components/run/RunSetup.tsx`): the prompt carries its path, never the text (#68). The home HIDES the session view with CSS — the console never unmounts behind it.
+- **The workspace home** — `/workspace?lane=` opens on the workspace's home (`client/src/components/lanes/WorkspaceHome.tsx`), shaped like a Claude Desktop project page: the name (rename from its `⋯` menu, which is the sidebar row menu) with the repository it belongs to under it (root, the main worktree's branch, the worktree count, from `GET /api/projects` — the facts Settings → Projects used to show), a prompt box — the one place a session starts WITH a prompt (a pane has no run form; the session view's tab bar `+` only starts a bare CLI or a shell) — whose fields run in order **AI** (the session's own tool: every installed, enabled one, the workspace's by default), **AI Proxy** (Native or a saved profile; Native alone, disabled, for a tool that cannot use one), **Model** (the tool's own list, or on a router that router's models), **Permission** (Claude's permission mode; Codex's sandbox and approval; a catalog tool's per-session bypass switch) and **Effort** (Claude and Codex), whose half-typed text is kept per workspace in `agenthub.workspace.draft.` (localStorage, so it survives leaving the page or switching workspace; cleared once sent — attached images are not kept), and whose **Start** opens a NEW session in the first slot with no live run (0–9, `POST /api/lanes/:id/start {slot}`; all ten busy is refused) and then shows it in a tab of its own, and **Recents** — the workspace's own sessions (`?lane=`) with their cost and time, a live one opening its pane and any other its conversation (`&session=`), exactly like the sidebar. The transcript page (`/sessions/`) of a Claude session started in a workspace's own folder carries an explicit **Resume** (`client/src/components/lanes/SessionResumeButton.tsx`, the one place that resumes) and beside it **Fork**, which resumes the history into a NEW session id (`{forkSession: true}` → `--fork-session`, the original untouched, offered even while the original has a live pane): it warns that a resume re-reads the whole transcript into context, re-reads the live panes, then starts it in the workspace's first free pane (`POST /api/lanes/:id/start {slot, resumeSessionId}`) and opens it. A session from a SUBFOLDER gets no Resume — the pane starts in the workspace's folder and Claude finds a session by the folder it ran in — and a session that still has a live pane shows **Open pane** instead. On the right sit the workspace's own context: **Instructions** (appended to every new session's system prompt), **Memory** (read-only: the folder's CLAUDE.md / AGENTS.md family and Claude's auto memory), **Context** (uploaded files, ≤ 10 MB each, stored by AgentHub) and **Folder** (the workspace's own folder plus extra ones, with the repo's untracked worktrees suggested). Only Claude and Codex panes can take them (`--add-dir`, `--append-system-prompt-file=` / `-c developer_instructions=`); for any other tool the page says so and its panes start without them. See `docs/API.md` → Workspace context. Each extra folder is also its own repo in the **Changes** panel, stacked under the workspace folder's like VS Code's Source Control: once the workspace has one, every repo is a section with a foldable header, each a whole panel — Changes, Files, Review, History, commit and push — on its own folder (every git route with `?source=`, resolved from the `lane_sources` row; `ENOSOURCE` otherwise, never the workspace folder); the open sections share the height, and a folded one stays mounted. A chat's request to open a file unfolds the workspace folder's section. Right-clicking a section header offers **Close repository** (extra folders only — the workspace folder never closes) and **Close other repositories**; closing REMOVES the folder from the workspace (`DELETE /api/lanes/:id/sources/:sourceId`, the folder stays on disk), asking first only if that section holds an unsaved edit. A plain-text paste over 5,000 characters into any prompt box (the home, a chat, a finished session's reply) is attached as `pasted-text.txt` like a pasted file (`PASTE_AS_FILE_CHARS` in `client/src/components/run/RunSetup.tsx`): the prompt carries its path, never the text (#68). The home HIDES the session view with CSS — the console never unmounts behind it.
- **A session in its own worktree** — Claude Desktop's worktree toggle: the row under the home's prompt box has a **Local / New worktree** select (Claude workspaces in a git repo). Claude Code itself makes a worktree only when started with `--worktree` or when asked mid-session — and a worktree it enters mid-session is invisible to AgentHub, which reads a session's folder once, at its start, and the new session's pane runs `claude --worktree=`, so CLAUDE CODE makes `/.claude/worktrees/` on branch `worktree-`, copies `.worktreeinclude`, runs your `WorktreeCreate` hook, and on exit removes a worktree with no changes (or asks). AgentHub adds `/.claude/worktrees/` to the repo's `info/exclude` (Claude does not, and the main repo would list it). The session stays this workspace's own — listed with a branch icon and the worktree's name — and the session view's Changes panel works in ITS worktree (every git route with `?session=`, resolved from the session row: owned by the workspace, registered by `git worktree list`; `ENOWORKTREE` otherwise, never the main folder). Resume and Fork start in that worktree. **Archive** of a worktree session with no live pane asks first and removes the worktree like Claude Desktop — `git worktree remove` without force (git refuses unsaved work, and then nothing is archived) and `git branch -d` (unmerged work keeps its branch); a lock held by a live process refuses, a stale one is cleared. The workspace's own folder is never removed.
- **The session view** — `/workspace?lane=&run=` (a live session picked in the sidebar or Recents, or one just started) shows the console's tabs and the Changes panel, with that run's tab active. The sidebar's workspace row always opens the workspace's home, live tabs or not (a live tab opens from its session row); the **Home** button at the left end of the tab bar opens the home of a workspace whose tabs stay running. Every workspace keeps its own tabs, panes and sessions: another workspace's tab is never shown. A browser notification for a workspace with a live pane opens it here directly.
- **Untracked worktrees** are no longer listed in the sidebar; Settings → Git → **Worktrees** (`showUntracked`, hidden sources `claude` = `/.claude/worktrees/*` and `agenthub` = `LANES_ROOT`) now decides which of them the home's Add folder suggests.
diff --git a/package-lock.json b/package-lock.json
index ae708f25..e6911166 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "agenthub",
- "version": "2.6.0",
+ "version": "2.6.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
- "version": "2.6.0",
+ "version": "2.6.1",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
diff --git a/package.json b/package.json
index 370ddd51..fa502e6d 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "agenthub",
- "version": "2.6.0",
+ "version": "2.6.1",
"bin": {
"agenthub": "bin/agenthub.js"
},
diff --git a/server/__tests__/api.test.js b/server/__tests__/api.test.js
index e68397fa..eccf424e 100644
--- a/server/__tests__/api.test.js
+++ b/server/__tests__/api.test.js
@@ -3100,7 +3100,7 @@ describe("GET /api/sessions/:id/outline", () => {
hook_type: "UserPromptSubmit",
data: {
session_id: sid,
- prompt: `compare these ${drop}\n/tmp/agenthub-drops-1000/fedcba9876543210.png`,
+ prompt: `compare these ${drop}\n/home/u/.claude/agenthub/drops/fedcba9876543210.png`,
},
});
const mem = "/home/u/.claude/projects/-w/memory/release.md";
@@ -3113,7 +3113,7 @@ describe("GET /api/sessions/:id/outline", () => {
await pre("Write", { file_path: mem, content: "x" });
const { body } = await fetch(`/api/sessions/${sid}/outline`);
- assert.deepEqual(body.uploads, ["/tmp/agenthub-drops-1000/fedcba9876543210.png", drop]);
+ assert.deepEqual(body.uploads, ["/home/u/.claude/agenthub/drops/fedcba9876543210.png", drop]);
// a.ts was read, then edited: an output, no longer a file it only used.
assert.deepEqual(body.files, ["/w/src/c.ts"]);
assert.deepEqual(body.memory, [mem]);
diff --git a/server/__tests__/lane-sources.test.js b/server/__tests__/lane-sources.test.js
index 82b39b4b..3da780af 100644
--- a/server/__tests__/lane-sources.test.js
+++ b/server/__tests__/lane-sources.test.js
@@ -185,8 +185,8 @@ describe("lane-sources instructions and launch", () => {
let argv = null;
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") throw Object.assign(new Error("gone"), { status: 1 });
- // The CLI rides behind `sh -c sh` (pty-run.js).
- if (args[0] === "new-session") argv = args.slice(args.indexOf("--") + 5);
+ // The CLI rides behind `sh -c sh ` (pty-run.js).
+ if (args[0] === "new-session") argv = args.slice(args.indexOf("--") + 6);
return "";
});
pty.spawnRun({ laneId: lane.id, cwd: ws, initialPrompt: "-c fix it" });
diff --git a/server/__tests__/lanes-api.test.js b/server/__tests__/lanes-api.test.js
index 8a0c9440..cb632526 100644
--- a/server/__tests__/lanes-api.test.js
+++ b/server/__tests__/lanes-api.test.js
@@ -482,10 +482,11 @@ describe("lane start input", () => {
assert.equal(created[created.indexOf("-s") + 1], `agenthub-lane-${id}-1`);
assert.equal(created[created.indexOf("-c") + 1], cwd);
// After `--` only the fixed size-wait wrapper that execs the user's own
- // default shell (issue #55) — nothing from the request.
+ // default shell (issue #55) in the lane's folder (#69) — nothing from
+ // the request.
const argv = created.slice(created.indexOf("--") + 1);
assert.deepEqual(argv.slice(0, 2), ["sh", "-c"]);
- assert.equal(argv.length, 3);
+ assert.deepEqual(argv.slice(3), ["sh", cwd]);
assert.match(argv[2], /exec "\$SHELL" -l$/);
assert.ok(!created.includes("ignored"));
const lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
diff --git a/server/__tests__/pty-run.test.js b/server/__tests__/pty-run.test.js
index 0eb4b7ca..86fac2e4 100644
--- a/server/__tests__/pty-run.test.js
+++ b/server/__tests__/pty-run.test.js
@@ -10,6 +10,7 @@ const assert = require("node:assert/strict");
const path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");
+const { execFileSync } = require("node:child_process");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "pty-run-test-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
@@ -80,7 +81,7 @@ describe("pty-run", () => {
});
try {
const t0 = Date.now();
- const child = spawn("sh", ["-c", argv[2]], {
+ const child = spawn("sh", ["-c", argv[2], "sh", os.tmpdir()], {
env: { ...process.env, SHELL: "/bin/echo", PATH: `${bin}:${process.env.PATH}` },
});
let out = "";
@@ -168,7 +169,7 @@ describe("pty-run", () => {
const respawn = calls.find((c) => c[0] === "respawn-pane");
assert.ok(respawn, "expected the idle shell to be replaced by the argv");
const argv = respawn.slice(respawn.indexOf("--") + 1);
- assert.equal(argv[0], "claude");
+ assert.deepEqual(argv.slice(3, 6), ["sh", "/tmp/repo", "claude"]);
assert.deepEqual(argv.slice(-2), ["--resume", "abc12345"]);
});
@@ -507,21 +508,32 @@ describe("pty-run", () => {
}
/** The CLI's own command line: what tmux passes after the `--`, less the
- * `sh -c '; exec "$@"' sh` wrapper spawnRun puts first. */
+ * `sh -c '; exec "$@"' sh ` wrapper spawnRun
+ * puts first. */
function paneArgv(newSessionCall) {
const argv = newSessionCall.slice(newSessionCall.indexOf("--") + 1);
assert.deepEqual([argv[0], argv[1], argv[3]], ["sh", "-c", "sh"]);
- assert.match(argv[2], /session_attached.*exec "\$@"$/);
- return argv.slice(4);
+ assert.match(argv[2], /^cd -- "\$1".*session_attached.*exec "\$@"$/);
+ return argv.slice(5);
}
it("runs a Windows pane's command itself — no sh there — and a Terminal as tmux's default shell", () => {
const argv = ["C:\\Users\\u\\.local\\bin\\claude.exe", "--model", "opus"];
- assert.deepEqual(pty.paneArgv(argv, "win32"), argv);
- assert.deepEqual(pty.paneArgv(null, "win32"), []);
- assert.equal(pty.paneArgv(argv, "linux")[0], "sh");
- assert.deepEqual(pty.paneArgv(argv, "linux").slice(-3), argv);
- assert.match(pty.paneArgv(null, "linux")[2], /exec "\$SHELL" -l$/);
+ assert.deepEqual(pty.paneArgv(argv, "C:\\w", "win32"), argv);
+ assert.deepEqual(pty.paneArgv(null, "C:\\w", "win32"), []);
+ assert.equal(pty.paneArgv(argv, "/w", "linux")[0], "sh");
+ assert.deepEqual(pty.paneArgv(argv, "/w", "linux").slice(-3), argv);
+ assert.match(pty.paneArgv(null, "/w", "linux")[2], /exec "\$SHELL" -l$/);
+ });
+
+ it("starts a pane in its folder even where tmux skips -c (issue #69)", () => {
+ // A tmux server whose own cwd was deleted (a pruned plugin cache dir)
+ // ignores `-c` and hands every pane that deleted cwd.
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pane-cwd-"));
+ const argv = pty.paneArgv(["pwd"], dir, "linux");
+ const out = execFileSync(argv[0], argv.slice(1), { cwd: "/", encoding: "utf8" });
+ assert.equal(fs.realpathSync(out.trim()), fs.realpathSync(dir));
+ fs.rmSync(dir, { recursive: true });
});
it("hands a long prompt to the pane through a file, never tmux's argv (issue #68)", (t) => {
@@ -578,6 +590,7 @@ describe("pty-run", () => {
[
...call.slice(call.indexOf("--") + 2, call.indexOf("--") + 4),
"sh",
+ os.tmpdir(),
"printf",
"%s|",
"x y",
diff --git a/server/__tests__/update-check-release.test.js b/server/__tests__/update-check-release.test.js
index c3c3146d..6b3654e6 100644
--- a/server/__tests__/update-check-release.test.js
+++ b/server/__tests__/update-check-release.test.js
@@ -291,6 +291,39 @@ describe("getUpdatesStatus — release drives update_available", () => {
assert.match(status.manual_command, /git merge --ff-only v1\.6\.0/);
});
+ it("does not offer a fast-forward when HEAD holds commits the newer release lacks", async () => {
+ // The release squashed the local wip commits into a new one: neither side
+ // contains the other, so `merge --ff-only` could only fail.
+ const repo = makeVersionedRepo(tmpDir, "diverged", "1.5.0", null);
+ git(repo, ["checkout", "-b", "rel"]);
+ fs.writeFileSync(
+ path.join(repo, "package.json"),
+ JSON.stringify({ name: "fx", version: "1.6.0" })
+ );
+ git(repo, [...IDENT, "commit", "-am", "release"]);
+ git(repo, ["tag", "v1.6.0"]);
+ git(repo, ["checkout", "main"]);
+ fs.writeFileSync(path.join(repo, "wip.txt"), "wip");
+ git(repo, [...IDENT, "add", "."]);
+ git(repo, [...IDENT, "commit", "-m", "wip"]);
+ const status = await getUpdatesStatus(repo, {
+ skipFetch: true,
+ latestRelease: {
+ ok: true,
+ tag: "v1.6.0",
+ name: "1.6.0",
+ url: "u",
+ published_at: null,
+ notes: null,
+ },
+ });
+
+ assert.equal(status.update_available, true);
+ assert.equal(status.situation, "diverged_from_release");
+ assert.doesNotMatch(status.manual_command, /--ff-only/);
+ assert.match(status.situation_note, /v1\.6\.0/);
+ });
+
it("is up to date — not a dev build — when local version equals the release and no commits are ahead", async () => {
const repo = makeVersionedRepo(tmpDir, "level", "1.5.0", "v1.5.0");
const status = await getUpdatesStatus(repo, {
diff --git a/server/lib/pty-run.js b/server/lib/pty-run.js
index f8a50d9d..8225bd7c 100644
--- a/server/lib/pty-run.js
+++ b/server/lib/pty-run.js
@@ -81,20 +81,29 @@ const WAIT_FOR_CLIENT =
const CLIENT_WAIT_MS = 2000;
/**
- * A pane's tmux command: `argv` behind {@link WAIT_FOR_CLIENT}, or — with no
- * `argv` — the login shell behind it. Windows has no `sh` (a WinGet tmux runs
- * native programs), and an `sh -c` there died at once and took the session
- * with it, so it gets the command itself, or nothing for tmux's own default
- * shell.
+ * `sh` that moves into `$1` itself. tmux applies `-c` only while its server's
+ * own cwd still exists: the server is forked from the dashboard's cwd — a
+ * plugin cache dir, deleted by `pruneRetiredPluginRoots` after an update the
+ * server outlives — and from then on every pane started in that deleted dir,
+ * where `claude` dies at once and a shell prints `getcwd` errors (issue #69).
+ */
+const IN_CWD = 'cd -- "$1" 2>/dev/null; shift;';
+
+/**
+ * A pane's tmux command, run in `cwd`: `argv` behind {@link WAIT_FOR_CLIENT},
+ * or — with no `argv` — the login shell behind it. Windows has no `sh` (a
+ * WinGet tmux runs native programs), and an `sh -c` there died at once and
+ * took the session with it, so it gets the command itself, or nothing for
+ * tmux's own default shell.
* ponytail: no wait for the attach on Windows, so a pane may first draw at
* 80×24 (issue #55); port the wait to PowerShell if that shows.
*/
-function paneArgv(argv, platform = process.platform) {
+function paneArgv(argv, cwd, platform = process.platform) {
if (platform === "win32") return argv || [];
- // argv rides as sh's positional parameters, never inside the script text.
+ // cwd and argv ride as sh's positional parameters, never inside the script text.
return argv
- ? ["sh", "-c", `${WAIT_FOR_CLIENT} exec "$@"`, "sh", ...argv]
- : ["sh", "-c", `${WAIT_FOR_CLIENT} exec "$SHELL" -l`];
+ ? ["sh", "-c", `${IN_CWD} ${WAIT_FOR_CLIENT} exec "$@"`, "sh", cwd, ...argv]
+ : ["sh", "-c", `${IN_CWD} ${WAIT_FOR_CLIENT} exec "$SHELL" -l`, "sh", cwd];
}
const CODEX_SANDBOXES = new Set(["read-only", "workspace-write", "danger-full-access"]);
const CODEX_APPROVALS = new Set(["on-request", "never"]);
@@ -614,7 +623,7 @@ function spawnRun(args) {
if (!tmux.hasSession(id)) {
codexPool?.place();
- tmux.newSession({ name: id, cwd, argv: paneArgv(paneCmd), env });
+ tmux.newSession({ name: id, cwd, argv: paneArgv(paneCmd, cwd), env });
record();
schedulePaste();
} else if (SHELL_COMMANDS.has(tmux.paneCommand(id) || "")) {
@@ -625,7 +634,7 @@ function spawnRun(args) {
// still answered 200. Run the argv in the pane the user already sees
// instead of erroring or opening a second session.
codexPool?.place();
- tmux.sendCommand(id, paneCmd, env, cwd);
+ tmux.sendCommand(id, paneArgv(paneCmd, cwd), env, cwd);
record();
schedulePaste();
}
@@ -677,7 +686,7 @@ function spawnShell({ laneId, slot = 0, cwd }) {
const paneEnv = member ? require("./run-as").memberPaneEnv(member, env) : env;
// Started once the pane has its real size (WAIT_FOR_CLIENT, issue #55), as
// the login shell tmux would have run (tmux sets SHELL in the pane).
- tmux.newSession({ name: id, cwd, argv: paneArgv(null), env: paneEnv });
+ tmux.newSession({ name: id, cwd, argv: paneArgv(null, cwd), env: paneEnv });
dashboardRuns?.recordRun({
id,
sessionId: null,
diff --git a/server/lib/tmux.js b/server/lib/tmux.js
index 008fa074..61922c31 100644
--- a/server/lib/tmux.js
+++ b/server/lib/tmux.js
@@ -38,7 +38,9 @@ const { execFile, execFileSync } = require("node:child_process");
// try/catch and treats failure as "no session" — piping tmux's own
// "error connecting to ..." noise to the process's stderr just spams the
// journal on every poll while no tmux server is running yet.
-const EXEC_OPTS = { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] };
+// cwd "/": a tmux server is forked from the call that starts it and keeps
+// that cwd; once it is deleted, tmux ignores every pane's `-c` (issue #69).
+const EXEC_OPTS = { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"], cwd: "/" };
let execImpl = (args, opts = EXEC_OPTS) => execFileSync("tmux", args, opts);
// Linux: the tmux server lives in the cgroup of whatever started it — the
diff --git a/server/lib/update-check.js b/server/lib/update-check.js
index 2bb13026..d3e736a3 100644
--- a/server/lib/update-check.js
+++ b/server/lib/update-check.js
@@ -698,6 +698,16 @@ async function getUpdatesStatus(gitRoot = DEFAULT_ROOT, options = {}) {
`This dashboard runs from the Claude Code plugin cache (marketplace '${pluginInstall.marketplace}'), ` +
`so it can't update itself. Run the commands above, then '/reload-plugins' in Claude Code — ` +
`or just run '/agenthub-update', which does both and rebuilds the runtime.`;
+ } else if (release.ok && releaseIsNewer && commitsAheadOfRelease > 0) {
+ // HEAD carries commits the release lacks (e.g. local wip that the release
+ // squashed into a new commit): `merge --ff-only` can only fail, and how to
+ // reconcile is the user's call, so fetch the tag and say why.
+ situation = "diverged_from_release";
+ manualParts = [`cd "${root}"`, `git fetch ${canonicalRemote} --tags`];
+ situationNote =
+ `Your checkout has ${commitsAheadOfRelease} commit(s) that release ${release.tag} doesn't contain, ` +
+ `so it can't be fast-forwarded. If they're already in the release, move onto it with ` +
+ `'git reset --keep ${release.tag}'; otherwise merge or rebase onto ${release.tag}.`;
} else if (release.ok && releaseIsNewer) {
// Move onto the release TAG rather than the branch tip: the tag is what
// was published, and a fast-forward keeps the tracking branch intact (a
diff --git a/server/routes/sessions.js b/server/routes/sessions.js
index c54404f3..d639a799 100644
--- a/server/routes/sessions.js
+++ b/server/routes/sessions.js
@@ -379,9 +379,10 @@ const outlineEvents = db.prepare(
);
// A file the composer attached (`/api/run/drop-*` names it), as its path is
// pasted into the prompt; and a Claude Code memory file.
+// The drop dir is `/drops/`; older transcripts name `/agenthub-drops-/`.
const UPLOAD_RE =
- /(?:^|\s)(\S*\/agenthub-drops-\d+\/[0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.[A-Za-z0-9]+)/g;
-const isUpload = (p) => /\/agenthub-drops-\d+\//.test(p);
+ /(?:^|\s)(\S*\/(?:agenthub-drops-\d+|drops)\/[0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.[A-Za-z0-9]+)/g;
+const isUpload = (p) => /\/(?:agenthub-drops-\d+|drops)\/[0-9a-f]{16}/.test(p);
const isMemory = (p) => /\/\.claude\/.*\/memory\//.test(p);
/** Records `p` once, newest last. */
const touch = (set, p) => {