Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7543b8d62a | |||
| 4163f431bb |
@@ -7233,7 +7233,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
|||||||
<p
|
<p
|
||||||
class="text-[10px] text-status-warning/90 mt-1"
|
class="text-[10px] text-status-warning/90 mt-1"
|
||||||
>
|
>
|
||||||
UI build v2.5.6 (rebuild client to match)
|
UI build v2.5.8 (rebuild client to match)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
openapi: 3.0.3
|
openapi: 3.0.3
|
||||||
info:
|
info:
|
||||||
title: Agent Dashboard for Claude Code API
|
title: Agent Dashboard for Claude Code API
|
||||||
version: 2.5.6
|
version: 2.5.8
|
||||||
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
||||||
contact:
|
contact:
|
||||||
name: Nguyễn Ngọc Trí Vĩ
|
name: Nguyễn Ngọc Trí Vĩ
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.5.6",
|
"version": "2.5.8",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.5.6",
|
"version": "2.5.8",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.5.6",
|
"version": "2.5.8",
|
||||||
"bin": {
|
"bin": {
|
||||||
"agenthub": "bin/agenthub.js"
|
"agenthub": "bin/agenthub.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ const path = require("path");
|
|||||||
const { execFileSync, spawnSync } = require("child_process");
|
const { execFileSync, spawnSync } = require("child_process");
|
||||||
// What a failed command printed — plugin-bootstrap.js says why on STDOUT.
|
// What a failed command printed — plugin-bootstrap.js says why on STDOUT.
|
||||||
const { failureText } = require("./plugin-update-helper");
|
const { failureText } = require("./plugin-update-helper");
|
||||||
|
// psmux (the Windows `tmux`) has no `=name:` targets; tmux.js knows which form.
|
||||||
|
const { pane: paneTarget } = require("../server/lib/tmux");
|
||||||
|
|
||||||
const [, , runtimeDirArg, planJson] = process.argv;
|
const [, , runtimeDirArg, planJson] = process.argv;
|
||||||
|
|
||||||
@@ -170,7 +172,7 @@ function reloadPanes() {
|
|||||||
try {
|
try {
|
||||||
paneCmd = execFileSync(
|
paneCmd = execFileSync(
|
||||||
"tmux",
|
"tmux",
|
||||||
["display-message", "-p", "-t", `=${name}:`, "#{pane_current_command}"],
|
["display-message", "-p", "-t", paneTarget(name), "#{pane_current_command}"],
|
||||||
{ encoding: "utf8", timeout: 15_000, stdio: ["ignore", "pipe", "pipe"] }
|
{ encoding: "utf8", timeout: 15_000, stdio: ["ignore", "pipe", "pipe"] }
|
||||||
).trim();
|
).trim();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -190,8 +192,10 @@ function reloadPanes() {
|
|||||||
// Atomics.wait, not `sleep`: Windows has no sleep binary.
|
// Atomics.wait, not `sleep`: Windows has no sleep binary.
|
||||||
if (!first) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1000);
|
if (!first) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1000);
|
||||||
first = false;
|
first = false;
|
||||||
execFileSync("tmux", ["send-keys", "-t", `=${name}:`, "-l", command], { timeout: 15_000 });
|
execFileSync("tmux", ["send-keys", "-t", paneTarget(name), "-l", command], {
|
||||||
execFileSync("tmux", ["send-keys", "-t", `=${name}:`, "Enter"], { timeout: 15_000 });
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
execFileSync("tmux", ["send-keys", "-t", paneTarget(name), "Enter"], { timeout: 15_000 });
|
||||||
}
|
}
|
||||||
reloaded.push(name);
|
reloaded.push(name);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -169,3 +169,17 @@ describe("pty-attach", () => {
|
|||||||
assert.equal(killed, true);
|
assert.equal(killed, true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("attachBinary on Windows", () => {
|
||||||
|
it("names tmux with its extension — node-pty there never adds `.exe`", () => {
|
||||||
|
// A bare `tmux` made node-pty answer "File not found" on every attach, so
|
||||||
|
// the browser's terminal opened, closed and reconnected every 1.5 s.
|
||||||
|
const saved = process.env.PATH;
|
||||||
|
process.env.PATH = "";
|
||||||
|
try {
|
||||||
|
assert.equal(ptyAttach.attachBinary("win32"), "tmux.exe");
|
||||||
|
} finally {
|
||||||
|
process.env.PATH = saved;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -51,6 +51,36 @@ describe("tmux wrapper", () => {
|
|||||||
assert.deepEqual(calls[0], ["new-session", "-d", "-s", "agenthub-lane-1", "-c", "C:\\repo"]);
|
assert.deepEqual(calls[0], ["new-session", "-d", "-s", "agenthub-lane-1", "-c", "C:\\repo"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("names sessions bare on psmux, which has no `=name` targets, and keeps `=` on tmux", () => {
|
||||||
|
// psmux (the WinGet `tmux` on Windows) answers "no server running on
|
||||||
|
// session '=agenthub-lane-1'" for a session that is alive, so every
|
||||||
|
// `=` target read a live pane as gone.
|
||||||
|
const fake = (supportsExact) => {
|
||||||
|
const calls = [];
|
||||||
|
tmux.__setExecImpl(
|
||||||
|
(args) => {
|
||||||
|
calls.push(args);
|
||||||
|
if (args[0] === "list-sessions") return "agenthub-lane-1\n";
|
||||||
|
const t = args[args.indexOf("-t") + 1];
|
||||||
|
if (t && t.startsWith("=") && !supportsExact) throw new Error("no server running");
|
||||||
|
return "";
|
||||||
|
},
|
||||||
|
{ exact: null }
|
||||||
|
);
|
||||||
|
return calls;
|
||||||
|
};
|
||||||
|
|
||||||
|
let calls = fake(false);
|
||||||
|
assert.equal(tmux.hasSession("agenthub-lane-1"), true);
|
||||||
|
tmux.enableMouse("agenthub-lane-1");
|
||||||
|
assert.ok(calls.some((c) => c[0] === "set-option" && c[2] === "agenthub-lane-1"));
|
||||||
|
assert.ok(!calls.some((c) => c[0] === "set-option" && c[2].startsWith("=")));
|
||||||
|
|
||||||
|
calls = fake(true);
|
||||||
|
tmux.enableMouse("agenthub-lane-1");
|
||||||
|
assert.ok(calls.some((c) => c[0] === "set-option" && c[2] === "=agenthub-lane-1:"));
|
||||||
|
});
|
||||||
|
|
||||||
it("newSession builds the correct argv", () => {
|
it("newSession builds the correct argv", () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
tmux.__setExecImpl((args) => {
|
tmux.__setExecImpl((args) => {
|
||||||
|
|||||||
@@ -42,9 +42,14 @@ function __setSpawnImpl(fn) {
|
|||||||
* file), else `tmux` on PATH. A client must match its server — tmux installed
|
* file), else `tmux` on PATH. A client must match its server — tmux installed
|
||||||
* in a newer version while the server runs makes PATH's client fail every
|
* in a newer version while the server runs makes PATH's client fail every
|
||||||
* attach with "open terminal failed: not a terminal", though every other tmux
|
* attach with "open terminal failed: not a terminal", though every other tmux
|
||||||
* command still works. Linux only; elsewhere PATH's tmux as before.
|
* command still works. Linux only; elsewhere PATH's tmux as before — on
|
||||||
|
* Windows by its full file name: node-pty there looks the file up on PATH as
|
||||||
|
* given, never adding `.exe`, so a bare `tmux` failed every attach with
|
||||||
|
* "File not found" (the browser saw a socket that opened and closed, again
|
||||||
|
* every 1.5 s).
|
||||||
*/
|
*/
|
||||||
function attachBinary() {
|
function attachBinary(platform = process.platform) {
|
||||||
|
if (platform === "win32") return require("./ai-tools").which("tmux") || "tmux.exe";
|
||||||
const pid = tmux.serverPid();
|
const pid = tmux.serverPid();
|
||||||
if (!pid) return "tmux";
|
if (!pid) return "tmux";
|
||||||
const exe = `/proc/${pid}/exe`;
|
const exe = `/proc/${pid}/exe`;
|
||||||
@@ -70,7 +75,7 @@ function attach(ws, runId, { cols, rows }) {
|
|||||||
} catch {
|
} catch {
|
||||||
/* session gone or tmux unavailable — attach below will surface that */
|
/* session gone or tmux unavailable — attach below will surface that */
|
||||||
}
|
}
|
||||||
const pty = spawnImpl(attachBinary(), ["attach-session", "-t", `=${runId}`], {
|
const pty = spawnImpl(attachBinary(), ["attach-session", "-t", tmux.session(runId)], {
|
||||||
name: "xterm-256color",
|
name: "xterm-256color",
|
||||||
cols: cols || 80,
|
cols: cols || 80,
|
||||||
rows: rows || 24,
|
rows: rows || 24,
|
||||||
@@ -153,4 +158,4 @@ function attach(ws, runId, { cols, rows }) {
|
|||||||
// resolves a working native binding with no compiler needed.
|
// resolves a working native binding with no compiler needed.
|
||||||
__setSpawnImpl((...args) => require("@lydell/node-pty").spawn(...args));
|
__setSpawnImpl((...args) => require("@lydell/node-pty").spawn(...args));
|
||||||
|
|
||||||
module.exports = { attach, validateRunId, __setSpawnImpl };
|
module.exports = { attach, attachBinary, validateRunId, __setSpawnImpl };
|
||||||
|
|||||||
+44
-3
@@ -26,19 +26,58 @@ const { execFileSync } = require("node:child_process");
|
|||||||
const EXEC_OPTS = { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] };
|
const EXEC_OPTS = { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] };
|
||||||
let execImpl = (args) => execFileSync("tmux", args, EXEC_OPTS);
|
let execImpl = (args) => execFileSync("tmux", args, EXEC_OPTS);
|
||||||
|
|
||||||
function __setExecImpl(fn) {
|
// Whether this tmux takes `=name` targets: true for tmux itself, false for
|
||||||
|
// psmux (the WinGet `tmux` on Windows), null until a live session has shown
|
||||||
|
// which. A fake exec in a test is real tmux unless it says otherwise.
|
||||||
|
let exactTargets = null;
|
||||||
|
|
||||||
|
function __setExecImpl(fn, { exact = true } = {}) {
|
||||||
execImpl = fn;
|
execImpl = fn;
|
||||||
|
exactTargets = exact;
|
||||||
}
|
}
|
||||||
function __reset() {
|
function __reset() {
|
||||||
execImpl = (args) => execFileSync("tmux", args, EXEC_OPTS);
|
execImpl = (args) => execFileSync("tmux", args, EXEC_OPTS);
|
||||||
|
exactTargets = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Probe once, on a session that exists: `=name` failing where the bare name
|
||||||
|
* answers means this tmux has no exact-match syntax (psmux answers
|
||||||
|
* "no server running on session '=name'"), so every target there is the bare
|
||||||
|
* name. Until a session exists to ask, tmux's own syntax.
|
||||||
|
*/
|
||||||
|
function exact() {
|
||||||
|
if (exactTargets !== null) return exactTargets;
|
||||||
|
let first;
|
||||||
|
try {
|
||||||
|
first = String(execImpl(["list-sessions", "-F", "#{session_name}"]))
|
||||||
|
.split("\n")
|
||||||
|
.map((x) => x.trim())
|
||||||
|
.find(Boolean);
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!first) return true;
|
||||||
|
const answers = (t) => {
|
||||||
|
try {
|
||||||
|
execImpl(["has-session", "-t", t]);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (answers(`=${first}`)) exactTargets = true;
|
||||||
|
else if (answers(first)) exactTargets = false;
|
||||||
|
return exactTargets ?? true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// tmux resolves a bare `-t agenthub-lane-1` by PREFIX when no exact match
|
// tmux resolves a bare `-t agenthub-lane-1` by PREFIX when no exact match
|
||||||
// exists, so it would hit `agenthub-lane-12` once lane 1's session is gone —
|
// exists, so it would hit `agenthub-lane-12` once lane 1's session is gone —
|
||||||
// and killSession would kill the wrong lane. `=` forces an exact match; pane
|
// and killSession would kill the wrong lane. `=` forces an exact match; pane
|
||||||
// and option targets need the trailing `:` (tmux 3.4 rejects `=name` there).
|
// and option targets need the trailing `:` (tmux 3.4 rejects `=name` there).
|
||||||
const session = (name) => `=${name}`;
|
// psmux has neither form and names a session by its bare name.
|
||||||
const pane = (name) => `=${name}:`;
|
const session = (name) => (exact() ? `=${name}` : name);
|
||||||
|
const pane = (name) => (exact() ? `=${name}:` : name);
|
||||||
|
|
||||||
function hasSession(name) {
|
function hasSession(name) {
|
||||||
try {
|
try {
|
||||||
@@ -247,6 +286,8 @@ function serverVersion() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
session,
|
||||||
|
pane,
|
||||||
hasSession,
|
hasSession,
|
||||||
newSession,
|
newSession,
|
||||||
enableMouse,
|
enableMouse,
|
||||||
|
|||||||
@@ -121,6 +121,9 @@ function initPtyWebSocket(server) {
|
|||||||
try {
|
try {
|
||||||
attach(ws, runId, size || { cols: 80, rows: 24 });
|
attach(ws, runId, size || { cols: 80, rows: 24 });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// The browser only sees a socket that closed and retries every 1.5 s;
|
||||||
|
// this line is the one place that says why.
|
||||||
|
console.warn(`[WS] attach ${runId} failed:`, err.message);
|
||||||
try {
|
try {
|
||||||
ws.close(1008, err.message);
|
ws.close(1008, err.message);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
Reference in New Issue
Block a user