feat: pet chat window replaces the Sagi panel; Windows pane attach no longer crashes the server

- Sagi: clicking the pet opens its chat window directly (PetChat + SagiInfo
  status strip, Needs you, Mute / Clear alerts); SagiPanel removed.
- Chat view answers Claude's unnumbered folder-trust prompt (parseBareMenu).
- pty-attach: resize/write after a Windows attach exited, and node-pty's pipe
  errors, no longer throw out of the WebSocket handler.
- restart-helper: the server started by an update logs to
  data/agenthub-server.log instead of discarding its output.
This commit is contained in:
2026-10-16 10:27:22 +07:00
parent a58e95a957
commit f460fff406
16 changed files with 734 additions and 555 deletions
+39
View File
@@ -204,6 +204,45 @@ describe("pty-attach", () => {
assert.deepEqual(JSON.parse(a.__sent.at(-1).data), { type: "lead" });
});
it("survives a stopped pane: pipe errors, typing and resizes after the attach exited", () => {
// Windows node-pty re-throws a read-pipe error nobody listens for, and its
// write pipe has no listener at all: Stop (kill-session) ended the attach
// with EPIPE and the uncaught error killed the whole dashboard.
const ptys = [];
ptyAttach.__setSpawnImpl(() => {
const p = makeFakePty();
p.on = (evt, fn) => p.__emitter.on(evt, fn);
p._agent = { inSocket: new EventEmitter() };
p.resize = () => {
if (p.__gone) throw new Error("Cannot resize a pty that has already exited");
};
p.write = () => {
if (p.__gone) throw new Error("write EPIPE");
};
ptys.push(p);
return p;
});
const a = makeFakeWs();
const b = makeFakeWs();
a.close = b.close = () => {};
ptyAttach.attach(a, "agenthub-lane-1", { cols: 80, rows: 24 });
ptyAttach.attach(b, "agenthub-lane-1", { cols: 80, rows: 24 });
const epipe = Object.assign(new Error("read EPIPE"), { code: "EPIPE" });
assert.doesNotThrow(() => ptys[0].__emitter.emit("error", epipe));
assert.doesNotThrow(() => ptys[0]._agent.inSocket.emit("error", epipe));
ptys[0].__gone = true;
ptys[0].__emitter.emit("exit", { exitCode: 1 }); // a's close event not yet in
const msg = (ws, data, binary) => ws.__emitter.emit("message", Buffer.from(data), { binary });
assert.doesNotThrow(() =>
msg(b, JSON.stringify({ type: "resize", cols: 90, rows: 30, drive: true }), false)
);
assert.doesNotThrow(() => msg(a, "x", true));
ptys[1].__gone = true; // exited, onExit still queued
assert.doesNotThrow(() => msg(b, "y", true));
});
it("kills the PTY attach process when the WS connection closes", () => {
const ws = makeFakeWs();
ptyAttach.attach(ws, "agenthub-lane-1", { cols: 80, rows: 24 });
+30 -2
View File
@@ -3,12 +3,17 @@
* @description After an update, a server our service unit runs is restarted
* by the unit, never by a detached replacement: v2.7.2 on the S3 system unit
* left the replacement holding the port while `Restart=always` started the
* unit every 3 s, each start exiting "already running".
* unit every 3 s, each start exiting "already running". Without a unit, the
* detached helper's replacement server logs to `data/agenthub-server.log`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { scheduleRestart, owningUnit } = require("../lib/self-restart");
function deps(unit, restartAnswer = "restarted") {
@@ -23,8 +28,9 @@ function deps(unit, restartAnswer = "restarted") {
},
}),
kill: () => calls.kill++,
spawnHelper: () => {
spawnHelper: (_exe, args) => {
calls.spawn++;
calls.helperArgs = args;
return { unref() {} };
},
};
@@ -84,5 +90,27 @@ describe("self-restart", () => {
assert.equal(scheduleRestart("/r", d), "helper");
assert.equal(d.calls.spawn, 1);
assert.equal(d.calls.kill, 1);
// The new server logs where `agenthub start` does — never into nothing.
assert.equal(d.calls.helperArgs.at(-1), path.join("/r", "data", "agenthub-server.log"));
});
it("restart-helper appends the new server's stdout and stderr to the log", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "restart-helper-"));
const entry = path.join(dir, "entry.js");
const log = path.join(dir, "data", "agenthub-server.log");
fs.writeFileSync(entry, 'console.log("up"); throw new Error("boom");\n');
const helper = path.join(__dirname, "..", "..", "scripts", "restart-helper.js");
// pid 0x7fffffff is never alive, so the helper restarts at once.
execFileSync(process.execPath, [helper, "2147483647", entry, log]);
const deadline = Date.now() + 5000;
let text = "";
while (Date.now() < deadline && !/boom/.test(text)) {
await new Promise((r) => setTimeout(r, 50));
text = fs.existsSync(log) ? fs.readFileSync(log, "utf8") : "";
}
fs.rmSync(dir, { recursive: true, force: true });
assert.match(text, /restart-helper: old server 2147483647 gone/);
assert.match(text, /^up$/m);
assert.match(text, /Error: boom/);
});
});