feat(run,ui): make the lane console usable — terminal focus, live-exit recovery, auto permission mode, model aliases
Terminal (issue #33): - `convertEol` is gone from xterm. It rewrote every bare LF as CR+LF, so a tmux pane that scrolled (tmux emits a bare LF and keeps drawing at the column it was on) had the rest of that draw land at column 0, stamping a line's first characters into the left margin of the rows below — the torn left edge that only showed up once content scrolled. - The attach carries the client's real size (`?cols=&rows=`, `sizeFromQuery` in `server/websocket.js`, bounded because it reaches a TIOCSWINSZ ioctl). `attach()` spawned `tmux attach-session` at a hardcoded 80x24 on every attach, and tmux sizes the window to the newest client, so `claude` redrew its whole UI at 80 columns until the first `resize` message landed. - The terminal takes the keyboard on mount, so typing reaches tmux without clicking the pane first. - A pane that ends (double Ctrl+C, `exit`, a crash) reports itself through a new `onExit` prop and `LaneConsolePane` runs `newRun()`, so the console returns to the setup form instead of leaving a dead pane behind a "New run" button. Pane environment: - `server/lib/desktop-env.js` forwards the graphical session's DISPLAY / WAYLAND_DISPLAY / XAUTHORITY / XDG_RUNTIME_DIR (from `systemctl --user show-environment`, this process's own values winning) into the pane. The server normally runs as a systemd user service with none of them, so `claude` there could not read the clipboard and answered "no image found in clipboard" on every image paste. Run setup: - `auto` is a permission mode and is now the default everywhere — allowlists, the `--permission-mode` fallback, the select, and the pane's initial state. - The model picker sends the CLI's aliases (`fable`/`opus`/`sonnet`/`haiku`) instead of pinned ids. `claude --model` resolves an alias to the latest model itself, so a release like Fable 5.1 needs no edit here; pricing is unaffected because it keys off the resolved id the transcript reports. - **Create/update readme file** moved from the Add-lane modal into the lane's Tools menu: creating a lane and writing its docs are separate decisions, and the second is worth re-running long after the first. - The Workspace's no-lane state is a real banner; the lane strip (add tile included) renders only once a lane exists. Refs #32, #33. Tests: npm run test:server (1828 pass), client vitest (558 pass).
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* @file desktop-env.test.js
|
||||
* @description Unit tests for the graphical-session variables forwarded into a
|
||||
* lane's tmux pane. Without them a `claude` started by the dashboard has no
|
||||
* DISPLAY/WAYLAND_DISPLAY at all and answers "no image found in clipboard" on
|
||||
* a paste, while the same CLI works in a normal terminal.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
const { describe, it, beforeEach, afterEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { desktopEnv, FORWARDED, __setExecImpl } = require("../lib/desktop-env");
|
||||
|
||||
const saved = {};
|
||||
|
||||
describe("desktopEnv", () => {
|
||||
beforeEach(() => {
|
||||
for (const name of FORWARDED) {
|
||||
saved[name] = process.env[name];
|
||||
delete process.env[name];
|
||||
}
|
||||
});
|
||||
afterEach(() => {
|
||||
for (const name of FORWARDED) {
|
||||
if (saved[name] === undefined) delete process.env[name];
|
||||
else process.env[name] = saved[name];
|
||||
}
|
||||
});
|
||||
|
||||
it("reads the systemd user manager's environment", () => {
|
||||
__setExecImpl(
|
||||
() => "XDG_RUNTIME_DIR=/run/user/1001\nDISPLAY=:0\nXAUTHORITY=/run/user/1001/gdm/Xauthority\n"
|
||||
);
|
||||
assert.deepEqual(desktopEnv(), {
|
||||
DISPLAY: ":0",
|
||||
XAUTHORITY: "/run/user/1001/gdm/Xauthority",
|
||||
XDG_RUNTIME_DIR: "/run/user/1001",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers this process's own value — a checkout run from a terminal has the real one", () => {
|
||||
process.env.DISPLAY = ":1";
|
||||
__setExecImpl(() => "DISPLAY=:0\n");
|
||||
assert.equal(desktopEnv().DISPLAY, ":1");
|
||||
});
|
||||
|
||||
it("forwards nothing when there is no systemd user manager (macOS, a container)", () => {
|
||||
__setExecImpl(() => {
|
||||
throw new Error("systemctl: not found");
|
||||
});
|
||||
assert.deepEqual(desktopEnv(), {});
|
||||
});
|
||||
|
||||
it("forwards only the clipboard-related names, never the whole session", () => {
|
||||
__setExecImpl(() => "DISPLAY=:0\nSSH_AUTH_SOCK=/tmp/agent\nPATH=/usr/bin\n");
|
||||
assert.deepEqual(Object.keys(desktopEnv()), ["DISPLAY"]);
|
||||
});
|
||||
|
||||
it("drops a value carrying a newline — it becomes a tmux -e argv", () => {
|
||||
__setExecImpl(() => "");
|
||||
process.env.WAYLAND_DISPLAY = "wayland-0\ninjected";
|
||||
assert.equal(desktopEnv().WAYLAND_DISPLAY, undefined);
|
||||
});
|
||||
});
|
||||
@@ -193,7 +193,10 @@ describe("pty-run", () => {
|
||||
// `--dangerously-skip-permissions` (which would enable it) is never passed.
|
||||
assert.ok(!newSessionArgv.includes("--dangerously-skip-permissions"));
|
||||
const modeAt = newSessionArgv.indexOf("--permission-mode");
|
||||
assert.equal(newSessionArgv[modeAt + 1], "acceptEdits");
|
||||
// `auto` is the default when the caller named no mode: the CLI's own
|
||||
// classifier asks for what it reads as risky and stays out of the way
|
||||
// otherwise.
|
||||
assert.equal(newSessionArgv[modeAt + 1], "auto");
|
||||
});
|
||||
|
||||
it("spawnRun appends a positional initial prompt after argv flags", () => {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* @file websocket-pty-size.test.js
|
||||
* @description Regression test for issue #33: the `/ws-pty/:runId` attach
|
||||
* spawns `tmux attach-session` at the size the CLIENT reports in the attach
|
||||
* URL, not a hardcoded 80×24. tmux sizes the window to the newest client, so
|
||||
* attaching at 80 columns and fixing it from the first `resize` message made
|
||||
* `claude` redraw its whole UI at 80 columns — wreckage that stays in the
|
||||
* scrollback and shows up as torn left-margin fragments when scrolling back.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
const { describe, it } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { sizeFromQuery } = require("../websocket");
|
||||
|
||||
const query = (search) => new URL(`http://x/ws-pty/agenthub-lane-1${search}`).searchParams;
|
||||
|
||||
describe("sizeFromQuery — the attach carries the client's real size", () => {
|
||||
it("reads the terminal's own cols/rows", () => {
|
||||
assert.deepEqual(sizeFromQuery(query("?cols=226&rows=29")), { cols: 226, rows: 29 });
|
||||
});
|
||||
|
||||
it("falls back to 80x24 when the client named no size", () => {
|
||||
assert.deepEqual(sizeFromQuery(query("")), { cols: 80, rows: 24 });
|
||||
});
|
||||
|
||||
it("refuses anything out of bounds — this number reaches an ioctl", () => {
|
||||
assert.deepEqual(sizeFromQuery(query("?cols=0&rows=-5")), { cols: 80, rows: 24 });
|
||||
assert.deepEqual(sizeFromQuery(query("?cols=99999&rows=99999")), { cols: 80, rows: 24 });
|
||||
assert.deepEqual(sizeFromQuery(query("?cols=abc&rows=12.5")), { cols: 80, rows: 12 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* @file desktop-env.js
|
||||
* @description The graphical session's own environment variables, for panes
|
||||
* the dashboard spawns. The server usually runs as a systemd *user* service,
|
||||
* which has no `DISPLAY`/`WAYLAND_DISPLAY` of its own — so neither does any
|
||||
* tmux pane it starts, and a `claude` running in one cannot reach the X or
|
||||
* Wayland clipboard: pasting an image answers "no image found in clipboard"
|
||||
* while the same CLI in a normal terminal pastes fine. `systemctl --user
|
||||
* show-environment` is the canonical local source for those values (the
|
||||
* desktop session imports them there at login), with this process's own
|
||||
* environment preferred when it already has them — a checkout started from a
|
||||
* terminal inherits the real ones.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
/** The only names forwarded. A pane gets a clipboard, not the whole session. */
|
||||
const FORWARDED = ["DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "XDG_RUNTIME_DIR"];
|
||||
|
||||
let execImpl = (args) =>
|
||||
execFileSync("systemctl", args, {
|
||||
encoding: "utf8",
|
||||
timeout: 2000,
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
});
|
||||
|
||||
/** Test seam — same pattern as tmux.js. */
|
||||
function __setExecImpl(fn) {
|
||||
execImpl = fn;
|
||||
}
|
||||
|
||||
/** `KEY=value` lines from `systemctl --user show-environment`, as a map. */
|
||||
function systemdUserEnv() {
|
||||
let out;
|
||||
try {
|
||||
out = execImpl(["--user", "show-environment"]);
|
||||
} catch {
|
||||
// No systemd user manager (macOS, a container, a plain login shell) — the
|
||||
// process environment below is the only source then.
|
||||
return {};
|
||||
}
|
||||
const env = {};
|
||||
for (const line of String(out).split("\n")) {
|
||||
const eq = line.indexOf("=");
|
||||
if (eq > 0) env[line.slice(0, eq)] = line.slice(eq + 1);
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* The forwarded variables that actually have a usable value right now.
|
||||
* A value carrying a newline is dropped rather than passed on: these end up in
|
||||
* a `tmux -e NAME=value` argv, and a line break there is not a value tmux can
|
||||
* represent.
|
||||
*/
|
||||
function desktopEnv() {
|
||||
const fromSystemd = systemdUserEnv();
|
||||
const out = {};
|
||||
for (const name of FORWARDED) {
|
||||
const value = process.env[name] || fromSystemd[name];
|
||||
if (typeof value === "string" && value && !/[\r\n\0]/.test(value)) out[name] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
module.exports = { desktopEnv, FORWARDED, __setExecImpl };
|
||||
+19
-3
@@ -26,6 +26,7 @@
|
||||
|
||||
const tmux = require("./tmux");
|
||||
const { routerArgvForSpawn, routerEnvForSpawn } = require("./router-apply");
|
||||
const { desktopEnv } = require("./desktop-env");
|
||||
|
||||
let dashboardRuns = null;
|
||||
try {
|
||||
@@ -36,7 +37,16 @@ try {
|
||||
|
||||
const RUN_ID_RE = /^agenthub-lane-(\d+)$/;
|
||||
const EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]);
|
||||
const ALLOWED_PERMISSION_MODES = new Set(["acceptEdits", "default", "plan", "bypassPermissions"]);
|
||||
// `auto` is the CLI's own default-ish mode (`claude --permission-mode auto`):
|
||||
// it asks only for what its classifier reads as risky. `default` is kept for
|
||||
// callers that stored it, though this CLI now spells that one `manual`.
|
||||
const ALLOWED_PERMISSION_MODES = new Set([
|
||||
"auto",
|
||||
"acceptEdits",
|
||||
"default",
|
||||
"plan",
|
||||
"bypassPermissions",
|
||||
]);
|
||||
// Pane commands that mean "idle shell prompt, safe to type a command into".
|
||||
const SHELL_COMMANDS = new Set(["sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh"]);
|
||||
const VALID_TOOLS = new Set(["claude", "codex"]);
|
||||
@@ -70,7 +80,7 @@ function makeErr(code, message) {
|
||||
*/
|
||||
function buildClaudeArgv({ model, permissionMode, effort, resumeSessionId, initialPrompt }) {
|
||||
const argv = ["claude"];
|
||||
argv.push("--permission-mode", permissionMode || "acceptEdits");
|
||||
argv.push("--permission-mode", permissionMode || "auto");
|
||||
// Puts "bypass permissions" INTO the pane's shift+tab cycle without turning
|
||||
// it on: `claude --help` — "Enable bypassing all permission checks as an
|
||||
// option, without it being enabled by default." Without it the cycle is only
|
||||
@@ -235,7 +245,7 @@ function spawnRun(args) {
|
||||
cwd,
|
||||
tool: tool === "codex" ? "codex" : "claude",
|
||||
model: model || null,
|
||||
permissionMode: permissionMode || "acceptEdits",
|
||||
permissionMode: permissionMode || "auto",
|
||||
effort: effort || null,
|
||||
resumeSessionId: resumeSessionId || null,
|
||||
prompt: initialPrompt || "",
|
||||
@@ -254,8 +264,14 @@ function spawnRun(args) {
|
||||
// A codex lane on an AI Router also needs the key its layer's `env_key`
|
||||
// names. A claude lane needs nothing here — its settings file already carries
|
||||
// the router — and a lane on the vendor's own API needs nothing either.
|
||||
// The graphical session's own DISPLAY/WAYLAND_DISPLAY goes in too: the
|
||||
// server normally runs as a systemd user service with neither, so a pane it
|
||||
// starts has no clipboard at all — pasting an image into `claude` there
|
||||
// answers "no image found in clipboard" while the same CLI pastes fine in a
|
||||
// terminal. Never overrides what this spawn already needs.
|
||||
const env = {
|
||||
AGENTHUB_LANE_ID: String(laneId),
|
||||
...desktopEnv(),
|
||||
...routerEnvForSpawn({ tool, router_profile: routerProfile || null }),
|
||||
};
|
||||
|
||||
|
||||
@@ -102,10 +102,10 @@ const schemas = {
|
||||
},
|
||||
permissionMode: {
|
||||
type: "string",
|
||||
enum: ["acceptEdits", "default", "plan", "bypassPermissions"],
|
||||
enum: ["auto", "acceptEdits", "default", "plan", "bypassPermissions"],
|
||||
description:
|
||||
"Permission mode passed via `--permission-mode`. Defaults to `acceptEdits` when omitted or invalid.",
|
||||
example: "acceptEdits",
|
||||
"Permission mode passed via `--permission-mode`. Defaults to `auto` when omitted or invalid.",
|
||||
example: "auto",
|
||||
},
|
||||
effort: {
|
||||
type: "string",
|
||||
@@ -316,9 +316,9 @@ const schemas = {
|
||||
permission_mode: {
|
||||
type: "string",
|
||||
nullable: true,
|
||||
enum: ["acceptEdits", "default", "plan", "bypassPermissions"],
|
||||
enum: ["auto", "acceptEdits", "default", "plan", "bypassPermissions"],
|
||||
description: "Permission mode recorded at spawn time.",
|
||||
example: "acceptEdits",
|
||||
example: "auto",
|
||||
},
|
||||
effort: {
|
||||
type: "string",
|
||||
@@ -516,11 +516,11 @@ const schemas = {
|
||||
},
|
||||
permissionMode: {
|
||||
type: "string",
|
||||
enum: ["acceptEdits", "default", "plan", "bypassPermissions"],
|
||||
default: "acceptEdits",
|
||||
enum: ["auto", "acceptEdits", "default", "plan", "bypassPermissions"],
|
||||
default: "auto",
|
||||
description:
|
||||
"Permission mode passed via `--permission-mode`. Unknown values silently fall back to `acceptEdits`.",
|
||||
example: "acceptEdits",
|
||||
"Permission mode passed via `--permission-mode`. Unknown values silently fall back to `auto`.",
|
||||
example: "auto",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -60,7 +60,13 @@ function sanitiseCwd(input) {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
const ALLOWED_PERMISSION_MODES = new Set(["acceptEdits", "default", "plan", "bypassPermissions"]);
|
||||
const ALLOWED_PERMISSION_MODES = new Set([
|
||||
"auto",
|
||||
"acceptEdits",
|
||||
"default",
|
||||
"plan",
|
||||
"bypassPermissions",
|
||||
]);
|
||||
|
||||
router.get("/", (_req, res) => {
|
||||
res.json({ items: runs.listRuns() });
|
||||
@@ -248,7 +254,7 @@ router.post("/", (req, res) => {
|
||||
permissionMode:
|
||||
typeof body.permissionMode === "string" && ALLOWED_PERMISSION_MODES.has(body.permissionMode)
|
||||
? body.permissionMode
|
||||
: "acceptEdits",
|
||||
: "auto",
|
||||
effort: typeof body.effort === "string" && body.effort ? body.effort : null,
|
||||
resumeSessionId:
|
||||
typeof body.resumeSessionId === "string" && body.resumeSessionId
|
||||
|
||||
+28
-4
@@ -82,6 +82,23 @@ function initWebSocket(server) {
|
||||
let ptyWss = null;
|
||||
const PTY_PATH_RE = /^\/ws-pty\/(agenthub-lane-\d+)$/;
|
||||
|
||||
/**
|
||||
* The attaching terminal's own size, off the attach URL's query. tmux resizes
|
||||
* the window to whichever client attached most recently, so a hardcoded 80×24
|
||||
* here is not a harmless default: the pane's program redraws its whole UI at
|
||||
* 80 columns and only gets the real width back when the client's first
|
||||
* `resize` message lands, leaving 80-column wreckage in the scrollback that
|
||||
* shows up as torn left-margin fragments when the user scrolls back
|
||||
* (issue #33). Bounded, because this number reaches a `TIOCSWINSZ` ioctl.
|
||||
*/
|
||||
function sizeFromQuery(params) {
|
||||
const read = (name, fallback) => {
|
||||
const parsed = Number.parseInt(params.get(name) ?? "", 10);
|
||||
return Number.isInteger(parsed) && parsed >= 2 && parsed <= 1000 ? parsed : fallback;
|
||||
};
|
||||
return { cols: read("cols", 80), rows: read("rows", 24) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Second WebSocket server, dedicated to the terminal-run PTY transport
|
||||
* (`/ws-pty/:runId`). Kept separate from the `/ws` JSON-broadcast path so
|
||||
@@ -100,9 +117,9 @@ function initPtyWebSocket(server) {
|
||||
const { attach } = require("./lib/pty-attach");
|
||||
ptyWss = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024 });
|
||||
|
||||
ptyWss.on("connection", (ws, runId) => {
|
||||
ptyWss.on("connection", (ws, runId, size) => {
|
||||
try {
|
||||
attach(ws, runId, { cols: 80, rows: 24 });
|
||||
attach(ws, runId, size || { cols: 80, rows: 24 });
|
||||
} catch (err) {
|
||||
try {
|
||||
ws.close(1008, err.message);
|
||||
@@ -133,7 +150,7 @@ function initPtyWebSocket(server) {
|
||||
return;
|
||||
}
|
||||
ptyWss.handleUpgrade(req, socket, head, (ws) => {
|
||||
ptyWss.emit("connection", ws, match[1]);
|
||||
ptyWss.emit("connection", ws, match[1], sizeFromQuery(url.searchParams));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -203,4 +220,11 @@ function closeWebSocket() {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { initWebSocket, initPtyWebSocket, broadcast, getConnectionCount, closeWebSocket };
|
||||
module.exports = {
|
||||
initWebSocket,
|
||||
initPtyWebSocket,
|
||||
broadcast,
|
||||
getConnectionCount,
|
||||
closeWebSocket,
|
||||
sizeFromQuery,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user