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:
2026-10-12 13:06:04 +07:00
parent 2fd0c903a2
commit 3a8be55b55
52 changed files with 1559 additions and 203 deletions
+3 -2
View File
@@ -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 &&
+38 -4
View File
@@ -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,
+15
View File
@@ -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,