sleep: 判斷式收尾與 sleeper 隔離

- sleeper 帶 --as-sleeper 就能在沒有 exclusive 鎖時做收尾
- sleeper 被 pin 在自己的人格上,讀寫其他人格(含主人格)一律攔下
- sleeper 只能走 CLI,不得用 Write 或 shell 重導向改人格檔案
- 主人格載入別的人格時,sleeper 仍以自己的 pin 為準

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-30 07:45:14 +00:00
co-authored by Claude Opus 5
parent 28b6230426
commit b3508a046b
5 changed files with 124 additions and 15 deletions
+29 -3
View File
@@ -22,6 +22,8 @@ const TEMPLATE_DIR = path.join(HERE, "..", "skills", "persona-create", "template
const SELF = path.join(HERE, "persona.mjs");
let QUIET = false;
// 本次呼叫的旗標(權限檢查要看 --as-sleeper--agent-id,不想改十幾個 requireOwner 呼叫點)
let CURRENT_FLAGS = {};
function die(message, code = 1) {
process.stderr.write(`${message}\n`);
@@ -49,7 +51,7 @@ function emit(payload, asJson, lines) {
// --------------------------------------------------------------------------- //
const FLAGS = new Set([
"json", "quiet", "force", "takeover", "as-guest", "on", "off", "with-meta", "all",
"json", "quiet", "force", "takeover", "as-guest", "as-sleeper", "on", "off", "with-meta", "all",
"with-journal", "gzip", "record", "load", "allow-repeat", "clear", "release", "keep-lock", "contact",
"if-due", "no-gitea", "public", "rename", "from-source",
]);
@@ -110,11 +112,30 @@ function requireSession(flags) {
// 權限檢查
// --------------------------------------------------------------------------- //
/** 呼叫者必須是這個人格的 exclusive 持有者。 */
/**
* 睡眠 sub agent 的寫入權:它替某個人格收尾,手上沒有 exclusive 鎖,只有 5 分鐘的 sleeper 租約。
* 第一個收尾指令會把租約取起來(`acquireSleepLease` 內含鎖檢查:目標正被別的程序載入著就拒絕),
* 之後每個指令續租,最後由 `sleep` 還掉。`--as-sleeper` 由 PreToolUse hook 把關,
* 只有 persona-sleeper 型的 sub agent 能用。
*/
function sleeperAccess(slug, sessionId, agentId) {
const mine = pl.liveSleepers(slug).find((s) => s.session_id === sessionId);
if (mine) {
pl.heartbeatSleepLease(slug, sessionId, mine.agent_id ?? null);
return mine;
}
return pl.acquireSleepLease(slug, sessionId, { agentId: agentId || null });
}
/** 呼叫者必須是這個人格的 exclusive 持有者(或替它收尾的 sleeper)。 */
function requireOwner(slug, sessionId) {
if (!slug) die("未指定人格,且本 session 沒有載入人格。");
if (!pl.personaExists(slug)) die(`人格 \`${slug}\` 不存在。可用:${pl.listPersonas().join(", ") || "(無)"}`);
const data = pl.loadSession(sessionId);
if (CURRENT_FLAGS["as-sleeper"]) {
sleeperAccess(slug, sessionId, str(CURRENT_FLAGS["agent-id"]));
return data;
}
if (data.host !== slug) {
die(
`本 session 的 host 人格是 \`${data.host || "(未載入)"}\`,不是 \`${slug}\`` +
@@ -826,7 +847,10 @@ commands.consolidate = ({ flags }) => {
if (flags["body-file"]) body = fs.readFileSync(str(flags["body-file"]), "utf8");
if (!body.trim()) die("需要 `--body` 或 `--body-file`。");
const type = str(flags.type) || "fact";
const VALID_TYPES = ["fact", "preference", "event", "promise", "relationship", "insight", "boundary", "canon"];
// diary:睡眠時寫的第一人稱回顧(persona-sleeppersona-sleeper 都用這個型別)
const VALID_TYPES = [
"fact", "preference", "event", "promise", "relationship", "insight", "boundary", "canon", "diary",
];
if (!VALID_TYPES.includes(type)) die(`--type 只能是 ${VALID_TYPES.join("/")}`);
const front = [
"---",
@@ -1809,6 +1833,7 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C
guard (內部)從 stdin 讀 hook event 測試隔離判斷
全域旗標:--json(機器可讀)、--quiet(成功時不輸出;劇場模式必用)
--as-sleeper(睡眠 sub agent 專用:以 sleeper 租約代替 exclusive 鎖,hook 會驗身分)
`;
async function main(argv) {
@@ -1821,6 +1846,7 @@ async function main(argv) {
if (!command) die(`未知子指令 \`${sub}\`。用 \`node persona.mjs --help\` 看清單。`);
const parsed = parseArgs(argv.slice(1));
QUIET = Boolean(parsed.flags.quiet);
CURRENT_FLAGS = parsed.flags;
try {
await command({ flags: parsed.flags, positional: parsed._ });
} catch (err) {