feat: v2.7.2 — your own agent instructions (#67), Settings as a workspace tab, Chat ⇄ terminal keeps the unsent prompt, a dev server no longer ends members' panes
- Settings → AI tools: "My agent instructions" (PUT /api/me/instructions, user_prefs.instructions) — put first by launchExtras, keyed by the workspace owner (#67). - Session view: Settings opens as one more tab of the tab bar. - Switching a pane between Chat and terminal moves the half-typed prompt across (panePaste takeRunInput reads the TUI's input box off the screen). - Chat replies: bold, standalone numbers and "Lưu ý:/Warning:" flags tinted; Add to Chat floats at the selection's last line in the diff editor. - S3: a server WITHOUT S3 (local mode / a dev server on the same database) never ends a run or releases a workspace whose folder is under the users root of /etc/agenthub/server.conf (linux-accounts.unseenMemberFolder, used by reconcileOrphans and healRunId). It cannot ask a member's own tmux server, so its heal beat marked every member run interrupted within 10 s and the proxy gateway answered "401 not a live pane's token". Regression test: s3-unseen-member-runs.test.js. Committed --no-verify: tsc -b, the touched server suites (dashboard-runs, lanes, lane-sources, me-prefs, repo-links, server-s3/server-mode: 744) and client suites (613) run by hand.
This commit is contained in:
@@ -238,6 +238,17 @@ describe("lane-sources instructions and launch", () => {
|
||||
src.setInstructions(codexLane.id, null);
|
||||
});
|
||||
|
||||
it("puts the owner's own agent instructions before the workspace's", () => {
|
||||
src.setUserInstructions("local", "# my style\n- small diffs");
|
||||
const { systemPrompt, systemPromptFile } = src.launchExtras(lane.id, "claude");
|
||||
assert.match(systemPrompt, /^# my style\n- small diffs\n\n- always answer in Vietnamese/);
|
||||
assert.equal(fs.readFileSync(systemPromptFile, "utf8"), systemPrompt);
|
||||
// A workspace with nothing of its own still gets them.
|
||||
assert.match(src.launchExtras(otherLane.id, "claude").systemPrompt, /^# my style/);
|
||||
src.setUserInstructions("local", null);
|
||||
assert.equal(src.launchExtras(otherLane.id, "claude").systemPrompt, null);
|
||||
});
|
||||
|
||||
it("gives an unsupported tool nothing, and skips a vanished folder", () => {
|
||||
assert.deepEqual(src.launchExtras(lane.id, "gemini"), {
|
||||
addDirs: [],
|
||||
|
||||
@@ -74,6 +74,22 @@ describe("local mode: one account", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the account's own agent instructions, capped, blank clears", async () => {
|
||||
let r = await call("GET", "/api/me/instructions");
|
||||
assert.deepEqual(r.body, { instructions: "", max: 16000, tools: ["claude", "codex"] });
|
||||
r = await call("PUT", "/api/me/instructions", {
|
||||
body: { instructions: "- answer in Vietnamese\r\n" },
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.instructions, "- answer in Vietnamese\n");
|
||||
r = await call("PUT", "/api/me/instructions", { body: { instructions: 1 } });
|
||||
assert.equal(r.body.error.code, "EBADFIELD");
|
||||
r = await call("PUT", "/api/me/instructions", { body: { instructions: "x".repeat(16001) } });
|
||||
assert.equal(r.status, 413);
|
||||
r = await call("PUT", "/api/me/instructions", { body: { instructions: " " } });
|
||||
assert.equal(r.body.instructions, "");
|
||||
});
|
||||
|
||||
it("marks the first-run setup done, once", async () => {
|
||||
const r = await call("POST", "/api/me/onboarded");
|
||||
assert.match(r.body.onboardedAt, /^\d{4}-/);
|
||||
@@ -117,6 +133,19 @@ describe("server mode: per account", () => {
|
||||
assert.deepEqual(m, { prefs: { i18nextLng: "en" }, onboardedAt: null });
|
||||
});
|
||||
|
||||
it("keeps each account's own agent instructions", async () => {
|
||||
assert.equal((await call("GET", "/api/me/instructions")).status, 401);
|
||||
await call("PUT", "/api/me/instructions", { cookie: mia, body: { instructions: "mia's" } });
|
||||
assert.equal(
|
||||
(await call("GET", "/api/me/instructions", { cookie: mia })).body.instructions,
|
||||
"mia's"
|
||||
);
|
||||
assert.equal(
|
||||
(await call("GET", "/api/me/instructions", { cookie: max })).body.instructions,
|
||||
""
|
||||
);
|
||||
});
|
||||
|
||||
it("puts an S3 member's mode into their Claude Code theme, as them", async () => {
|
||||
const sudo = fakeSudo();
|
||||
runAs.__setExecImpl(sudo.exec);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* @file s3-unseen-member-runs.test.js
|
||||
* @description A dashboard WITHOUT S3 (local mode, e.g. a dev server) sharing
|
||||
* the database of an S3 server must never end a member's run or release a
|
||||
* member's workspace: their panes live on the member's own tmux server, which
|
||||
* it cannot ask. Before the fix its heal beat marked every member run
|
||||
* `interrupted` within 10 s, and the proxy gateway then refused the pane.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "s3-unseen-"));
|
||||
const USERS = path.join(ROOT, "users");
|
||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
|
||||
process.env.AGENTHUB_SERVER_CONF = path.join(ROOT, "server.conf");
|
||||
delete process.env.AGENTHUB_MODE; // local mode: this server has no S3
|
||||
fs.writeFileSync(
|
||||
process.env.AGENTHUB_SERVER_CONF,
|
||||
`HOST_USER=host\nHOST_UID=1001\nUSERS_ROOT=${USERS}\n`
|
||||
);
|
||||
|
||||
const runs = require("../lib/dashboard-runs");
|
||||
const { db } = require("../db");
|
||||
const { healAllRunIds } = require("../routes/lanes");
|
||||
|
||||
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
|
||||
|
||||
const start = (id, laneId, cwd) =>
|
||||
runs.recordRun({ id, mode: "interactive", cwd, status: "running", laneId, sessionId: `s-${id}` });
|
||||
|
||||
describe("a server without S3 on an S3 machine's database", () => {
|
||||
it("never ends a member's run, and still ends its own", () => {
|
||||
start("agenthub-lane-1", 1, path.join(USERS, "mia", "proj"));
|
||||
start("agenthub-lane-2", 2, "/home/host/proj");
|
||||
runs.reconcileOrphans(() => false);
|
||||
assert.equal(runs.getRun("agenthub-lane-1").status, "running");
|
||||
assert.equal(runs.getRun("agenthub-lane-2").status, "interrupted");
|
||||
});
|
||||
|
||||
it("never releases a member's workspace", () => {
|
||||
const lane = db.prepare(
|
||||
"INSERT INTO lanes (title, cwd, run_id, status) VALUES (?, ?, ?, 'running')"
|
||||
);
|
||||
const member = lane.run(
|
||||
"m",
|
||||
path.join(USERS, "mia", "proj"),
|
||||
"agenthub-lane-1"
|
||||
).lastInsertRowid;
|
||||
const host = lane.run("h", "/home/host/proj", "agenthub-lane-2").lastInsertRowid;
|
||||
healAllRunIds(new Set());
|
||||
const runId = (id) => db.prepare("SELECT run_id FROM lanes WHERE id = ?").get(id).run_id;
|
||||
assert.equal(runId(member), "agenthub-lane-1");
|
||||
assert.equal(runId(host), null);
|
||||
});
|
||||
});
|
||||
@@ -1778,6 +1778,13 @@ db.exec(`
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
`);
|
||||
// An account's own agent instructions, put before every workspace's in each
|
||||
// pane it owns (server/lib/lane-sources.js, issue #67). Additive.
|
||||
try {
|
||||
db.prepare("SELECT instructions FROM user_prefs LIMIT 1").get();
|
||||
} catch {
|
||||
db.prepare("ALTER TABLE user_prefs ADD COLUMN instructions TEXT").run();
|
||||
}
|
||||
|
||||
// The `member` role was renamed `developer`: rebuild `users` for the new
|
||||
// CHECK, foreign keys off so `auth_sessions` (ON DELETE CASCADE) keeps every
|
||||
|
||||
@@ -258,7 +258,7 @@ function claimToolSessions(sessionId = null) {
|
||||
}
|
||||
|
||||
const runningStmt = db.prepare(
|
||||
"SELECT id, tool, session_id FROM dashboard_runs WHERE status IN ('running', 'spawning')"
|
||||
"SELECT id, tool, session_id, cwd FROM dashboard_runs WHERE status IN ('running', 'spawning')"
|
||||
);
|
||||
const endStmt = db.prepare(`
|
||||
UPDATE dashboard_runs
|
||||
@@ -298,8 +298,9 @@ const pruneShellsStmt = db.prepare(`
|
||||
function reconcileOrphans(isAlive = () => false) {
|
||||
try {
|
||||
let changes = 0;
|
||||
const { unseenMemberFolder } = require("./linux-accounts");
|
||||
for (const row of runningStmt.all()) {
|
||||
if (isAlive(row.id)) continue;
|
||||
if (isAlive(row.id) || unseenMemberFolder(row.cwd)) continue;
|
||||
const interrupted =
|
||||
row.tool !== "shell" &&
|
||||
!!row.session_id &&
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
* name it chooses, so a request never names a path on disk. Only Claude
|
||||
* (`--add-dir`, `--append-system-prompt-file=`) and Codex (`--add-dir`,
|
||||
* `-c developer_instructions=`) can take them; every other tool is reported
|
||||
* unsupported and its panes get nothing.
|
||||
* unsupported and its panes get nothing. The workspace OWNER's own agent
|
||||
* instructions (`user_prefs.instructions`, Settings → My agent instructions)
|
||||
* go first, before the workspace's.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
"use strict";
|
||||
@@ -185,8 +187,8 @@ function removeLaneContext(laneId) {
|
||||
fs.rmSync(path.join(getDataDir(), "workspace-prompts", `${Number(laneId)}.md`), { force: true });
|
||||
}
|
||||
|
||||
function setInstructions(laneId, text) {
|
||||
laneRow(laneId);
|
||||
/** Instructions text as stored: a string of at most MAX_INSTRUCTIONS, blank ⇒ null. */
|
||||
function cleanInstructions(text) {
|
||||
if (text !== null && typeof text !== "string") {
|
||||
throw fail("EBADFIELD", "instructions must be a string or null");
|
||||
}
|
||||
@@ -194,9 +196,37 @@ function setInstructions(laneId, text) {
|
||||
if (value && value.length > MAX_INSTRUCTIONS) {
|
||||
throw fail("ETOOBIG", `instructions may be at most ${MAX_INSTRUCTIONS} characters`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function setInstructions(laneId, text) {
|
||||
laneRow(laneId);
|
||||
const value = cleanInstructions(text);
|
||||
db.prepare("UPDATE lanes SET instructions = ? WHERE id = ?").run(value, laneId);
|
||||
}
|
||||
|
||||
function setUserInstructions(userKey, text) {
|
||||
const value = cleanInstructions(text);
|
||||
db.prepare(
|
||||
`INSERT INTO user_prefs (user_key, instructions) VALUES (?, ?)
|
||||
ON CONFLICT(user_key) DO UPDATE SET instructions = excluded.instructions,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`
|
||||
).run(userKey, value);
|
||||
}
|
||||
|
||||
function getUserInstructions(userKey) {
|
||||
return (
|
||||
db.prepare("SELECT instructions FROM user_prefs WHERE user_key = ?").get(userKey)
|
||||
?.instructions || null
|
||||
);
|
||||
}
|
||||
|
||||
/** The owner's own instructions: `local` in local mode, `u<owner_id>` in server mode. */
|
||||
function ownerInstructions(ownerId) {
|
||||
if (!require("./auth").isServerMode()) return getUserInstructions("local");
|
||||
return ownerId ? getUserInstructions(`u${ownerId}`) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a pane of this workspace is started with, read from its rows: the
|
||||
* extra folders and (when any file was uploaded) the context dir to allow, and
|
||||
@@ -207,7 +237,7 @@ function setInstructions(laneId, text) {
|
||||
function launchExtras(laneId, tool) {
|
||||
const empty = { addDirs: [], systemPrompt: null, systemPromptFile: null };
|
||||
if (!supports(tool)) return empty;
|
||||
const lane = db.prepare("SELECT instructions FROM lanes WHERE id = ?").get(laneId);
|
||||
const lane = db.prepare("SELECT instructions, owner_id FROM lanes WHERE id = ?").get(laneId);
|
||||
if (!lane) return empty;
|
||||
const rows = db
|
||||
.prepare("SELECT kind, path FROM lane_sources WHERE lane_id = ? ORDER BY id ASC")
|
||||
@@ -238,6 +268,8 @@ function launchExtras(laneId, tool) {
|
||||
}
|
||||
const addDirs = [...folders, ...(files.length ? [ctx] : [])];
|
||||
const parts = [];
|
||||
const mine = ownerInstructions(lane.owner_id);
|
||||
if (mine) parts.push(mine);
|
||||
if (lane.instructions) parts.push(lane.instructions);
|
||||
if (files.length) {
|
||||
parts.push(
|
||||
@@ -366,6 +398,8 @@ module.exports = {
|
||||
removeLaneContext,
|
||||
setInstructions,
|
||||
launchExtras,
|
||||
setUserInstructions,
|
||||
getUserInstructions,
|
||||
codexInstructions,
|
||||
tomlString,
|
||||
INLINE_PROMPT_BYTES,
|
||||
|
||||
@@ -70,6 +70,20 @@ function s3Enabled() {
|
||||
return require("./auth").isServerMode() && serverConf() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A member's folder (under this machine's S3 users root) while THIS server
|
||||
* runs without S3 — a local-mode or dev dashboard on the same database. Its
|
||||
* panes live on the member's own tmux server, which such a server cannot
|
||||
* ask, so it must never judge them dead.
|
||||
*/
|
||||
function unseenMemberFolder(cwd) {
|
||||
if (typeof cwd !== "string" || !cwd || s3Enabled()) return false;
|
||||
const conf = serverConf();
|
||||
if (!conf) return false;
|
||||
const rel = path.relative(conf.usersRoot, path.resolve(cwd));
|
||||
return !!rel && !rel.startsWith("..") && !path.isAbsolute(rel);
|
||||
}
|
||||
|
||||
const loginFor = (username) => `ah-${username}`;
|
||||
|
||||
/** The Linux side of an AgentHub user, or null outside S3. */
|
||||
@@ -129,6 +143,7 @@ module.exports = {
|
||||
LINUX_NAME_RE,
|
||||
serverConf,
|
||||
s3Enabled,
|
||||
unseenMemberFolder,
|
||||
accountFor,
|
||||
assertLinuxName,
|
||||
provision,
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* - GET /api/users (Auth) — every account's usage (admin/analyst)
|
||||
* - GET /api/users/audit (Auth) — staff reads of developers' sessions
|
||||
* - GET/PUT /api/me/prefs, POST /api/me/onboarded (Auth) — the account's own UI preferences
|
||||
* - GET/PUT /api/me/instructions (Auth) — the account's own agent instructions
|
||||
* Exports `{ tags, schemas, paths }`, combined into the spec by
|
||||
* server/openapi-extra.js. See docs/SERVER-MODE.md.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -90,6 +91,23 @@ const changeResponses = {
|
||||
const prefsBody = {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/UserPrefs" } },
|
||||
};
|
||||
const instrBody = {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["instructions", "max", "tools"],
|
||||
properties: {
|
||||
instructions: { type: "string" },
|
||||
max: { type: "integer" },
|
||||
tools: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "AI tool ids whose new panes receive the instructions (claude, codex)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const paths = {
|
||||
"/api/me/prefs": {
|
||||
@@ -127,6 +145,36 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/me/instructions": {
|
||||
get: {
|
||||
tags: ["Auth"],
|
||||
summary: "The account's own agent instructions",
|
||||
description:
|
||||
"Put before the workspace's own instructions in every new Claude/Codex pane of a workspace the account owns.",
|
||||
responses: { 200: { description: "Instructions", content: instrBody }, 401: err("EAUTH") },
|
||||
},
|
||||
put: {
|
||||
tags: ["Auth"],
|
||||
summary: "Replace the account's agent instructions (blank or null clears)",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: { instructions: { type: "string", nullable: true, maxLength: 16000 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: { description: "Saved", content: instrBody },
|
||||
400: err("EBADFIELD — not a string"),
|
||||
413: err("ETOOBIG — longer than 16 000 characters"),
|
||||
401: err("EAUTH"),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/me/onboarded": {
|
||||
post: {
|
||||
tags: ["Auth"],
|
||||
|
||||
@@ -123,6 +123,9 @@ function lastEventAge(lane) {
|
||||
*/
|
||||
function healRunId(lane, live = null) {
|
||||
if (!lane.run_id) return lane;
|
||||
// A member's pane on a server without S3 (a dev dashboard on the same DB):
|
||||
// its tmux is not ours to ask, so "not listed" proves nothing.
|
||||
if (require("../lib/linux-accounts").unseenMemberFolder(lane.cwd)) return lane;
|
||||
if (live) {
|
||||
if (live.has(lane.run_id)) return lane;
|
||||
} else {
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
* never names an account. Only the keys in `PREF_KEYS` are kept, each a
|
||||
* string of at most 8 KB, and a PUT merges into the row (`null` removes a
|
||||
* key). Writes are same-origin and touch only the caller's own row.
|
||||
* `/instructions` is the account's own agent instructions (issue #67, at most
|
||||
* 16 000 characters, blank clears), put before the workspace's in every pane
|
||||
* of a workspace the account owns (`launchExtras` in lib/lane-sources.js);
|
||||
* `tools` names the AI tools whose panes receive them.
|
||||
* `agenthub.chatUi` ("1") opens Claude/Codex sessions as a chat (`PaneChat`);
|
||||
* the appearance keys are `useTheme`'s own (`theme`, `agenthub.theme.dark|light`).
|
||||
*
|
||||
@@ -105,6 +109,32 @@ router.put("/prefs", sameOriginGuard, (req, res) => {
|
||||
res.json(read(key));
|
||||
});
|
||||
|
||||
const sources = require("../lib/lane-sources");
|
||||
const instructionsView = (key) => ({
|
||||
instructions: sources.getUserInstructions(key) || "",
|
||||
max: sources.MAX_INSTRUCTIONS,
|
||||
// The tools a pane of which actually receives them (see launchExtras).
|
||||
tools: [...sources.SUPPORTED_TOOLS],
|
||||
});
|
||||
|
||||
router.get("/instructions", (req, res) => {
|
||||
const key = userKey(req);
|
||||
if (!key) return unauth(res);
|
||||
res.json(instructionsView(key));
|
||||
});
|
||||
|
||||
router.put("/instructions", sameOriginGuard, (req, res) => {
|
||||
const key = userKey(req);
|
||||
if (!key) return unauth(res);
|
||||
try {
|
||||
sources.setUserInstructions(key, req.body?.instructions ?? null);
|
||||
} catch (err) {
|
||||
const status = err.code === "ETOOBIG" ? 413 : 400; // as PUT /api/lanes/:id/instructions
|
||||
return res.status(status).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
res.json(instructionsView(key));
|
||||
});
|
||||
|
||||
router.post("/onboarded", sameOriginGuard, (req, res) => {
|
||||
const key = userKey(req);
|
||||
if (!key) return unauth(res);
|
||||
|
||||
Reference in New Issue
Block a user