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
+43 -3
View File
@@ -581,6 +581,20 @@ export function acquireSleepLease(slug, sessionId, { agentId = null } = {}) {
return lease;
}
/** 續租:收尾要跑好幾個指令(固化、日記、心智圖…),別讓租約在中途過期。 */
export function heartbeatSleepLease(slug, sessionId, agentId = null) {
const data = readJson(sleepersPath(slug), {}) ?? {};
let touched = false;
for (const lease of data.sleepers || []) {
if (lease.session_id !== sessionId) continue;
if (agentId !== null && lease.agent_id && lease.agent_id !== agentId) continue;
lease.heartbeat_at = nowIso();
touched = true;
}
if (touched) writeJson(sleepersPath(slug), data);
return touched;
}
export function dropSleepLease(slug, sessionId, agentId = null) {
const data = readJson(sleepersPath(slug), {}) ?? {};
data.sleepers = (data.sleepers || []).filter(
@@ -1311,7 +1325,22 @@ export function upsertRelationNode(slug, node) {
const data = loadRelations(slug);
const nodeId = node.id || slugify(node.name || "");
node.id = nodeId;
const idx = data.nodes.findIndex((n) => n.id === nodeId);
let idx = data.nodes.findIndex((n) => n.id === nodeId);
// 同一個人不該因為換了 id(例如原本用名字當 id,後來改用人格編號 ASUNA-01)就多長一個節點:
// 找不到 id 但找得到同名節點時,就地換 id,並把指向舊 id 的連線一起改過去。
if (idx < 0 && node.name) {
const byName = data.nodes.findIndex((n) => n.name === node.name);
if (byName >= 0) {
const oldId = data.nodes[byName].id;
idx = byName;
if (oldId !== nodeId) {
for (const edge of data.edges) {
if (edge.from === oldId) edge.from = nodeId;
if (edge.to === oldId) edge.to = nodeId;
}
}
}
}
if (idx >= 0) {
for (const [k, v] of Object.entries(node)) if (v !== null && v !== undefined) data.nodes[idx][k] = v;
data.nodes[idx].updated_at = nowIso();
@@ -1691,6 +1720,7 @@ export function cliInvocation(command) {
personas: [],
session: null,
asGuest: /--as-guest\b/.test(command),
asSleeper: /--as-sleeper\b/.test(command),
};
const sub = command.match(/persona\.(?:mjs|js|py)['"]?\s+([a-z][a-z0-9-]*)/);
if (sub) info.subcommand = sub[1];
@@ -1804,13 +1834,17 @@ export function guardDecide(event) {
}
// first-touch pinning:第一個提到的人格就是它要睡的那個,之後不得換人
if (!scope.allowed && agentId && info.personas.length) pinAgent(sessionId, agentId, info.personas[0]);
}
if (scope.role === "guest") {
// sleeper 的範圍已經由它自己的 pin 決定,不能再套用 host 的判定
// (否則主人格 load 著別人時,sleeper 連自己的收尾指令都會被擋)。
} else if (scope.role === "guest") {
if (!GUEST_SAFE_SUBCOMMANDS.has(sub)) {
return deny(
`guest 人格(sub agent)僅能執行 ${[...GUEST_SAFE_SUBCOMMANDS].sort().join("/")},不得執行 \`${sub}\``,
);
}
if (info.asSleeper) {
return deny("`--as-sleeper` 只有 persona-sleeper 型的 sub agent 能用;guest 是來聊天的,不是來收尾的。");
}
for (const slug of info.personas) {
if (scope.allowed.length && !scope.allowed.includes(slug)) {
return deny(`guest 只能操作被邀請的人格 ${JSON.stringify(scope.allowed)},不得碰 \`${slug}\``);
@@ -1822,6 +1856,12 @@ export function guardDecide(event) {
"`--as-guest` 只有 persona-guest 型的 sub agent 能用;主程序不得以受邀人格的身分存取它的資料。",
);
}
if (info.asSleeper) {
return deny(
"`--as-sleeper` 只有 persona-sleeper 型的 sub agent 能用;" +
"要請別的人格收尾請走 /jsc-persona:persona-sleep(它會替那個人格開一個 sleeper)。",
);
}
for (const slug of info.personas) {
if (OWNER_EXEMPT_SUBCOMMANDS.has(sub)) continue;
if (scope.host && slug !== scope.host) {