Compare commits
3 Commits
46e3899945
...
b3fe10a4f3
| Author | SHA1 | Date | |
|---|---|---|---|
| b3fe10a4f3 | |||
| 8ecb96e9fc | |||
| c47dd230d4 |
@@ -1,6 +1,6 @@
|
||||
# Claude Code Working Guide
|
||||
|
||||
_Last synced: v2.7.4 (2026-10-12) — Changes hides a staged change undone on disk (#72); chat says when the session has not started (#73)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
|
||||
_Last synced: v2.7.4 (2026-10-12) — Changes hides a staged change undone on disk (#72); chat says when the session has not started (#73); a member's missing folder is made as them, bypass offered only once accepted._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
|
||||
|
||||
## Project mission
|
||||
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
||||
|
||||
+1
-1
@@ -359,7 +359,7 @@ Error `409 ESESSIONLIVE` is returned when `resumeSessionId` (or `resume`) names
|
||||
|
||||
Error `400 EBADPROMPT` is returned for a `prompt` (or `POST /api/run`'s `initialPrompt`) over 128 KB: no CLI can take one argument that long (Linux's `MAX_ARG_STRLEN`, issue #68) — attach the text as a file instead. A prompt over 4 KB never rides in tmux's argv (one tmux message holds 16 KB, `command too long`): outside Windows it is written to `<data dir>/run-prompts/<run id>.txt` (0600) and the pane's own `sh` reads it back as the CLI's last argument and deletes the file. An `after-start` tool's paste still goes through `tmux set-buffer` and stays under that 16 KB.
|
||||
|
||||
Error `409 ENOCWD` is returned when the workspace's folder no longer exists (or is not a directory) — the pane would otherwise start in the account's home under this workspace's name. A folder this server cannot read (`EACCES`) is not refused.
|
||||
Error `409 ENOCWD` is returned when the workspace's folder no longer exists (or is not a directory) — the pane would otherwise start in the account's home under this workspace's name. A folder this server cannot read (`EACCES`) is not refused. Server mode with one Linux user per account: a missing folder inside a member's own home is created instead, as that member (`mkdir -p` through `write-as`) — their home itself can be no workspace, so this is how a new member's first project folder comes to exist.
|
||||
|
||||
Error `409 ERUNLIVE` is returned if the workspace already has a live run; stop it first with the `stop` action. The same code and status come back when the workspace's tmux pane is already running an agent while a `prompt` or `resumeSessionId` was sent — that argv cannot be typed over a live agent, so the request is refused instead of answered 200 with nothing done. A start that asks for neither still adopts the existing pane silently.
|
||||
|
||||
|
||||
+14
-2
@@ -822,8 +822,20 @@ agenthub users provision alice # the Linux account only (a user made before
|
||||
writes three `auth.log` lines); a pane the dashboard starts or stops shows at
|
||||
once, one that exits on its own within those 5 s.
|
||||
- Claude Code does not carry a folder's trust down from the member's home
|
||||
(which provisioning trusts): a member answers "Trust this folder" once per
|
||||
workspace, in the terminal.
|
||||
(which provisioning trusts): a member may be asked "Trust this folder" once
|
||||
per workspace, in the terminal. AgentHub never answers it for them — it is
|
||||
what keeps a cloned repository's own settings and hooks from running unasked.
|
||||
- A member's first project folder: a workspace named on a folder that does not
|
||||
exist yet inside their home is created at its first start, AS the member
|
||||
(from v2.7.4; their home itself is no workspace).
|
||||
- Bypass in a pane's shift+tab cycle (`--allow-dangerously-skip-permissions`)
|
||||
is offered only to an account that already accepted Claude Code's bypass
|
||||
warning (`skipDangerousModePermissionPrompt` in its `~/.claude/settings.json`)
|
||||
or to a run that starts in bypass itself. Before v2.7.4 every new member's
|
||||
first session opened on that warning, which the Chat UI cannot answer.
|
||||
- A developer's System page (`GET /api/settings/info`, `GET /api/updates/status`)
|
||||
carries no path: not the database's, the hooks file's, the install folder,
|
||||
nor the transcript cache's keys — those are every user's transcript paths.
|
||||
|
||||
### Undo and uninstall
|
||||
|
||||
|
||||
@@ -374,7 +374,27 @@ describe("pty-run", () => {
|
||||
if (args[0] === "new-session") newSessionArgv = args;
|
||||
return "";
|
||||
});
|
||||
pty.spawnRun({ laneId: 4, cwd: "/tmp/repo" });
|
||||
const prevHome = process.env.CLAUDE_HOME;
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "bypass-home-"));
|
||||
process.env.CLAUDE_HOME = home;
|
||||
try {
|
||||
// An account that never accepted the bypass warning: no flag, so its
|
||||
// first session does not open on that warning (the Chat UI can't answer).
|
||||
pty.spawnRun({ laneId: 4, cwd: "/tmp/repo" });
|
||||
assert.ok(!newSessionArgv.includes("--allow-dangerously-skip-permissions"));
|
||||
// ...unless the run asks for bypass itself: then the warning is wanted.
|
||||
pty.spawnRun({ laneId: 4, cwd: "/tmp/repo", permissionMode: "bypassPermissions" });
|
||||
assert.ok(newSessionArgv.includes("--allow-dangerously-skip-permissions"));
|
||||
fs.writeFileSync(
|
||||
path.join(home, "settings.json"),
|
||||
JSON.stringify({ skipDangerousModePermissionPrompt: true })
|
||||
);
|
||||
pty.spawnRun({ laneId: 4, cwd: "/tmp/repo" });
|
||||
} finally {
|
||||
if (prevHome === undefined) delete process.env.CLAUDE_HOME;
|
||||
else process.env.CLAUDE_HOME = prevHome;
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
assert.ok(newSessionArgv.includes("--allow-dangerously-skip-permissions"));
|
||||
// ...as an OPTION only: the starting mode stays the requested one, and
|
||||
// `--dangerously-skip-permissions` (which would enable it) is never passed.
|
||||
|
||||
@@ -148,3 +148,23 @@ describe("server mode: a member's skills/commands come from their own workspaces
|
||||
assert.ok(names(r).includes("max-skill"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("the System page's facts for a developer", () => {
|
||||
// Every string that looks like an absolute path, anywhere in a body.
|
||||
const paths = (o, out = []) => {
|
||||
if (typeof o === "string" && o.startsWith("/")) out.push(o);
|
||||
else if (o && typeof o === "object") for (const v of Object.values(o)) paths(v, out);
|
||||
return out;
|
||||
};
|
||||
|
||||
it("carries no host or other-user path (DB, hooks file, transcript cache keys)", async () => {
|
||||
const mia = await login("mia", "member-pass-1");
|
||||
const info = await call("/api/settings/info", mia);
|
||||
assert.equal(info.status, 200);
|
||||
assert.deepEqual(paths(info.body), []);
|
||||
assert.equal(typeof info.body.db.size, "number", "the numbers stay");
|
||||
const boss = await login("boss", "admin-pass-1");
|
||||
const full = await call("/api/settings/info", boss);
|
||||
assert.ok(full.body.db.path, "an admin still sees the host's paths");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -173,3 +173,22 @@ describe("an analyst's workspace is theirs, like a developer's", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("a member's new project folder", () => {
|
||||
it("is made as the member, and nothing else is made for anyone", () => {
|
||||
const { fakeSudo } = require("./fixtures/fake-sudo");
|
||||
const sudo = fakeSudo();
|
||||
runAs.__setExecImpl(sudo.exec);
|
||||
const fresh = path.join(USERS, "mia", "brand-new", "sub");
|
||||
assert.equal(runAs.makeMemberFolder(fresh), true);
|
||||
assert.ok(fs.statSync(fresh).isDirectory());
|
||||
const call = sudo.calls.find((c) => c.includes("/opt/agenthub/libexec/write-as"));
|
||||
assert.ok(call && call.includes("ah-mia") && call.includes(fresh) && call.includes("mkdir"));
|
||||
sudo.calls.length = 0;
|
||||
assert.equal(runAs.makeMemberFolder(path.join(USERS, "mia")), false, "the home itself");
|
||||
assert.equal(runAs.makeMemberFolder(path.join(TMP, "host-new")), false, "not a member's");
|
||||
assert.equal(runAs.makeMemberFolder(path.join(USERS, "nobody", "x")), false, "no account");
|
||||
assert.deepEqual(sudo.calls, []);
|
||||
assert.equal(fs.existsSync(path.join(TMP, "host-new")), false);
|
||||
});
|
||||
});
|
||||
|
||||
+29
-1
@@ -165,6 +165,24 @@ function promptFileArgv(id, argv) {
|
||||
return ["sh", "-c", READ_PROMPT, "sh", file, ...argv.slice(0, -1)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the account a pane runs as has accepted Claude Code's bypass
|
||||
* warning — Claude writes `skipDangerousModePermissionPrompt: true` to its
|
||||
* user settings once it is. A member's (S3) is read from their home, owner-
|
||||
* checked; the host's from CLAUDE_HOME. Unreadable reads as "not yet".
|
||||
*/
|
||||
function bypassAccepted(member) {
|
||||
const file = member
|
||||
? path.join(member.home, ".claude", "settings.json")
|
||||
: path.join(require("./claude-home").getClaudeHome(), "settings.json");
|
||||
try {
|
||||
const text = require("./run-as").readFileSafe(file, "utf8", { max: 1 << 20 });
|
||||
return JSON.parse(text).skipDangerousModePermissionPrompt === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function buildClaudeArgv({
|
||||
model,
|
||||
permissionMode,
|
||||
@@ -177,6 +195,7 @@ function buildClaudeArgv({
|
||||
addDirs = [],
|
||||
systemPromptFile = null,
|
||||
proxyArgv = [],
|
||||
offerBypass = true,
|
||||
platform = process.platform,
|
||||
}) {
|
||||
const argv = ["claude"];
|
||||
@@ -210,7 +229,11 @@ function buildClaudeArgv({
|
||||
// plan/auto/manual/acceptEdits, so a run started in any non-bypass mode could
|
||||
// never reach bypass from inside the pane, while a real terminal could.
|
||||
// The starting mode is still whatever --permission-mode says above.
|
||||
argv.push("--allow-dangerously-skip-permissions");
|
||||
// Only once the account has accepted Claude Code's bypass warning
|
||||
// (`offerBypass`, see bypassAccepted): before that the flag alone puts the
|
||||
// warning in front of a brand-new account's first session — one the Chat UI
|
||||
// cannot answer — although nobody asked for bypass.
|
||||
if (offerBypass) argv.push("--allow-dangerously-skip-permissions");
|
||||
if (model) argv.push("--model", model);
|
||||
if (effort && EFFORT_LEVELS.has(effort)) argv.push("--effort", effort);
|
||||
if (resumeSessionId) argv.push("--resume", resumeSessionId);
|
||||
@@ -512,6 +535,11 @@ function spawnRun(args) {
|
||||
resumeSessionId,
|
||||
forkSession: forkSession === true,
|
||||
newSessionId,
|
||||
// Asking for bypass is consent to see its warning; otherwise only an
|
||||
// account that already accepted it gets bypass in its shift+tab cycle.
|
||||
offerBypass:
|
||||
permissionMode === "bypassPermissions" ||
|
||||
(entry.launcher === "claude" && bypassAccepted(require("./run-as").accountForLane(laneId))),
|
||||
worktreeName: worktree === true ? `ah-${laneId}-${Date.now().toString(36)}` : null,
|
||||
resumeLast,
|
||||
bypass: bypass === true,
|
||||
|
||||
@@ -488,6 +488,26 @@ function sameOwner(...paths) {
|
||||
|
||||
/** `mkdir -p` / `rm -rf` of a path in a member's home, AS the member. */
|
||||
const mkdirAs = (acct, dir) => writeAs(acct, dir, "mkdir");
|
||||
|
||||
/**
|
||||
* S3: make a missing workspace folder inside a member's own home, AS them
|
||||
* (`mkdir -p`, so only where they could themselves). A new member has no
|
||||
* other way to their first project: their home is no workspace (EFOLDER).
|
||||
* False — nothing made — for any other folder, the home itself, or a member
|
||||
* with no usable account.
|
||||
*/
|
||||
function makeMemberFolder(dir) {
|
||||
let acct;
|
||||
try {
|
||||
acct = accountForPath(dir);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const abs = path.resolve(dir);
|
||||
if (!acct || abs === acct.home) return false;
|
||||
mkdirAs(acct, abs);
|
||||
return true;
|
||||
}
|
||||
const removeAs = (acct, p) => writeAs(acct, p, "remove");
|
||||
|
||||
/** `[cmd, argv]` for `command args` in `cwd`: as its member, else as is. */
|
||||
@@ -528,6 +548,7 @@ module.exports = {
|
||||
handoffWrite,
|
||||
writeAs,
|
||||
mkdirAs,
|
||||
makeMemberFolder,
|
||||
removeAs,
|
||||
launchArgv,
|
||||
sameOwner,
|
||||
|
||||
@@ -2078,7 +2078,9 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
if (!fs.statSync(lane.cwd).isDirectory())
|
||||
throw Object.assign(new Error(), { code: "ENOTDIR" });
|
||||
} catch (err) {
|
||||
if (err.code === "ENOENT" || err.code === "ENOTDIR") {
|
||||
// A member's new project folder is made for them, as them.
|
||||
const made = err.code === "ENOENT" && require("../lib/run-as").makeMemberFolder(lane.cwd);
|
||||
if (!made && (err.code === "ENOENT" || err.code === "ENOTDIR")) {
|
||||
return res.status(409).json({
|
||||
error: {
|
||||
code: "ENOCWD",
|
||||
|
||||
@@ -35,7 +35,7 @@ const {
|
||||
importExportBundle,
|
||||
exportableRun,
|
||||
} = require("../lib/data-transfer");
|
||||
const { sessionWhere, bySessionWhere } = require("../lib/scope");
|
||||
const { sessionWhere, bySessionWhere, memberId } = require("../lib/scope");
|
||||
|
||||
function getDbSize() {
|
||||
try {
|
||||
@@ -127,15 +127,20 @@ router.get("/info", (req, res) => {
|
||||
h1: getCount(60 * 60 * 1000),
|
||||
};
|
||||
|
||||
// A developer (server mode) gets the numbers, never a path of the host's:
|
||||
// the transcript cache is keyed by EVERY user's transcript path (their
|
||||
// folders, their session ids), and the DB/settings paths are the host's.
|
||||
const developer = memberId(req) != null;
|
||||
const cache = transcriptCache.stats();
|
||||
res.json({
|
||||
db: {
|
||||
path: DB_PATH,
|
||||
path: developer ? null : DB_PATH,
|
||||
size: dbSize,
|
||||
counts,
|
||||
pragmas,
|
||||
load_stats,
|
||||
},
|
||||
hooks: hookStatus,
|
||||
hooks: developer ? { installed: hookStatus.installed } : hookStatus,
|
||||
server: {
|
||||
version: APP_VERSION,
|
||||
uptime: process.uptime(),
|
||||
@@ -152,7 +157,7 @@ router.get("/info", (req, res) => {
|
||||
free_mem: os.freemem(),
|
||||
cpus: os.cpus().length,
|
||||
},
|
||||
transcript_cache: transcriptCache.stats(),
|
||||
transcript_cache: developer ? { ...cache, keys: undefined } : cache,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -24,9 +24,11 @@ const { schedulePluginUpdate } = require("../lib/plugin-update");
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get("/status", async (_req, res) => {
|
||||
router.get("/status", async (req, res) => {
|
||||
try {
|
||||
const status = await getUpdatesStatus();
|
||||
// The install folder is the host's; a developer sees versions only.
|
||||
if (require("../lib/scope").memberId(req) != null) status.repo_root = null;
|
||||
res.json(status);
|
||||
} catch (err) {
|
||||
res.status(500).json({
|
||||
|
||||
Reference in New Issue
Block a user