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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user