dsh-session-conductor 1.3.13 → 1.3.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -1
- package/cordis.patch.yml +0 -0
- package/lib/apply.js +28 -1243
- package/lib/client-parts/apply.js +0 -0
- package/lib/client-parts/components/compaction.js +0 -0
- package/lib/client-parts/components/conductor-settings.js +0 -0
- package/lib/client-parts/components/group.js +0 -0
- package/lib/client-parts/components/panel.js +0 -0
- package/lib/client-parts/components/processing.js +0 -0
- package/lib/client-parts/components/session-icon.js +0 -0
- package/lib/client-parts/components/settings.js +0 -0
- package/lib/client-parts/foundation/bootstrap.js +0 -0
- package/lib/client-parts/foundation/i18n.js +0 -0
- package/lib/client-parts/foundation/list-cache.js +0 -0
- package/lib/client-parts/foundation/styles.js +0 -0
- package/lib/core/config.js +0 -0
- package/lib/core/domain.js +0 -0
- package/lib/core/index-store.js +0 -0
- package/lib/core/lazy.js +0 -0
- package/lib/core/state.js +0 -0
- package/lib/core.js +0 -0
- package/lib/eio-repair.js +0 -0
- package/lib/features/base-routes.js +156 -0
- package/lib/features/context-usage/index.js +0 -0
- package/lib/features/continue/eligibility.js +0 -0
- package/lib/features/continue/scan.js +0 -0
- package/lib/features/continue/session.js +0 -0
- package/lib/features/inject/preset.js +0 -0
- package/lib/features/member-model/batch.js +97 -0
- package/lib/features/member-model/command.js +61 -7
- package/lib/features/member-model/create-teammate.js +0 -0
- package/lib/features/member-model/index.js +15 -1
- package/lib/features/member-model/lead.js +73 -0
- package/lib/features/member-model/locate.js +82 -1
- package/lib/features/member-model/override.js +0 -0
- package/lib/features/member-model/permission-command.js +138 -0
- package/lib/features/member-model/permission.js +213 -0
- package/lib/features/member-model/setup.js +0 -0
- package/lib/features/member-model/switch.js +137 -0
- package/lib/features/member-model/tools.js +324 -0
- package/lib/features/memory-recall/index.js +0 -0
- package/lib/features/rename/analysis.js +0 -0
- package/lib/features/rename/title.js +0 -0
- package/lib/features/rename/value.js +0 -0
- package/lib/features/session-action-routes.js +435 -0
- package/lib/features/session-messaging/index.js +0 -0
- package/lib/features/session-messaging/logic.js +0 -0
- package/lib/features/session-messaging/tools.js +0 -0
- package/lib/features/session-ops-routes.js +0 -0
- package/lib/features/settings-routes.js +400 -0
- package/lib/features/trash.js +0 -0
- package/lib/features/value-index.js +0 -0
- package/lib/features/value-report-routes.js +211 -0
- package/lib/group.js +0 -0
- package/lib/i18n/en.json +0 -0
- package/lib/i18n/zh.json +0 -0
- package/lib/index.js +0 -0
- package/lib/repair.js +0 -0
- package/lib/seq-gap-repair.js +0 -0
- package/lib/session-codec.js +0 -0
- package/lib/session-log.js +0 -0
- package/lib/sessions/detach.js +0 -0
- package/lib/sessions/events.js +0 -0
- package/lib/sessions/interruption.js +0 -0
- package/lib/sessions/list-cache.js +0 -0
- package/lib/sessions/list.js +0 -0
- package/lib/sessions/lookup.js +0 -0
- package/lib/sessions/route.js +0 -0
- package/lib/sessions/session-id.js +0 -0
- package/lib/sessions/tail-io.js +30 -0
- package/lib/sessions/turn.js +0 -0
- package/lib/shared/atomic-file.js +0 -0
- package/lib/shared/constants.js +0 -0
- package/lib/shared/json-store.js +0 -0
- package/lib/shared/log.js +0 -0
- package/lib/shared/timing.js +0 -0
- package/lib/shared/util.js +0 -0
- package/lib/template-inject.js +0 -0
- package/lib/value.js +0 -0
- package/lib/zstd-frames.js +0 -0
- package/package.json +1 -1
- package/scripts/check-member-model.mjs +0 -0
- package/skills/dsh-session-conductor-functions.md +0 -0
- package/skills/dsh-session-conductor.md +0 -0
- package/skills/dsh-session-official-doc.md +0 -0
- package/skills/task-completion-report.md +0 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// dsh-session-conductor — 权限预设指令 `/perm-switch` 与 AI 工具 `set_member_permission`
|
|
2
|
+
//
|
|
3
|
+
// 【为什么需要指令】子代理会话在界面上无法手动切换权限预设(界面只对主会话开放),
|
|
4
|
+
// 而权限预设一旦指向当前不可用的值(典型:`auto` 在「自动审查」关闭后悬空),
|
|
5
|
+
// 该成员就永久唤不醒,且宿主只报一句 `subagent/not-resumable`,完全指不到权限层。
|
|
6
|
+
// 所以必须提供一条不经界面的入口:用户直接在聊天框切,AI 也能用工具切。
|
|
7
|
+
//
|
|
8
|
+
// 【三条形态】与 `/model-switch` 对齐:
|
|
9
|
+
// · /perm-switch → 列出成员与各自权限预设(并标出 auto 悬空告警)
|
|
10
|
+
// · /perm-switch <成员|sessionId> → 查该成员的 preset / sandbox / approval
|
|
11
|
+
// · /perm-switch <成员|sessionId> <预设> → 切换(追加 permission/preset 事件)
|
|
12
|
+
//
|
|
13
|
+
// 【注册方式照宿主官方范例】ctx.commands.register({ definitionId, name, description, input, handler }),
|
|
14
|
+
// definitionId 用 @deepseek-ai/dsh-commands/brand 的 CommandDefinitionId() 包一层。
|
|
15
|
+
// 全程动态导入宿主包:工作区自测环境没有 @deepseek-ai/* 依赖,静态导入会在导入期
|
|
16
|
+
// ERR_MODULE_NOT_FOUND 并连累整个插件加载失败;拿不到就跳过注册并留痕。
|
|
17
|
+
import { log } from "../../shared/log.js";
|
|
18
|
+
import {
|
|
19
|
+
describeMemberPermission,
|
|
20
|
+
findTargetAgent,
|
|
21
|
+
renderMemberPermissionList,
|
|
22
|
+
readPresetCatalog,
|
|
23
|
+
switchMemberPermission,
|
|
24
|
+
} from "./permission.js";
|
|
25
|
+
import { resolveTargets, isAllTarget } from "./locate.js";
|
|
26
|
+
import { readLeaderRoute } from "./lead.js";
|
|
27
|
+
import { applyPermissionToMany, renderBatchReport } from "./batch.js";
|
|
28
|
+
|
|
29
|
+
const COMMAND_NAME = "perm-switch";
|
|
30
|
+
const DEFINITION_ID = "@deepseek-ai/dsh-session-conductor/command-perm-switch";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 指令处理入口(导出便于单测直接驱动,不必走宿主)。
|
|
34
|
+
*
|
|
35
|
+
* @param {object} ctx 插件 context
|
|
36
|
+
* @param {{rawInput?: string, agent?: object}} invocation 宿主给的调用信息
|
|
37
|
+
* @returns {Promise<{kind: "success"|"error", text: string}>} 交给聊天框渲染
|
|
38
|
+
*/
|
|
39
|
+
export async function handlePermSwitchCommand(ctx, invocation) {
|
|
40
|
+
const raw = String(invocation?.rawInput ?? "").trim();
|
|
41
|
+
const parts = raw.split(/\s+/).filter(Boolean);
|
|
42
|
+
const caller = invocation?.agent ?? null;
|
|
43
|
+
try {
|
|
44
|
+
// 无参:列成员与各自预设(与批量执行分开,避免「空参数」有两种含义)
|
|
45
|
+
if (parts.length === 0) return { kind: "success", text: await renderMemberPermissionList(ctx, invocation) };
|
|
46
|
+
|
|
47
|
+
const targetRaw = parts[0];
|
|
48
|
+
if (parts.length === 1) {
|
|
49
|
+
if (isAllTarget(targetRaw)) {
|
|
50
|
+
return {
|
|
51
|
+
kind: "error",
|
|
52
|
+
text: `批量操作需要指定预设:\n /${COMMAND_NAME} * <预设名> ← 全部成员切到该预设\n /${COMMAND_NAME} * auto ← 全部成员跟随主代理(Lead)当前预设`,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const found = findTargetAgent(ctx, targetRaw, { caller });
|
|
56
|
+
if (found?.error) return { kind: "error", text: `找不到成员:${found.error}` };
|
|
57
|
+
return { kind: "success", text: (await describeMemberPermission(found)).join("\n") };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 二参:切换(单个或全部)
|
|
61
|
+
let preset = parts[1];
|
|
62
|
+
let followedLead = null;
|
|
63
|
+
if (preset.toLowerCase() === "auto") {
|
|
64
|
+
const lead = await readLeaderRoute(ctx, { caller });
|
|
65
|
+
if (lead.error) return { kind: "error", text: `跟随主代理失败:${lead.error}` };
|
|
66
|
+
if (!lead.preset) {
|
|
67
|
+
return { kind: "error", text: `主代理(${lead.sessionId})自己没有记录权限预设,无法跟随;请显式指定预设名` };
|
|
68
|
+
}
|
|
69
|
+
preset = lead.preset;
|
|
70
|
+
followedLead = lead;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const resolved = resolveTargets(ctx, targetRaw, { caller });
|
|
74
|
+
if (resolved?.error) return { kind: "error", text: `找不到成员:${resolved.error}` };
|
|
75
|
+
|
|
76
|
+
// 单个目标:沿用原有回执(含旋钮与可选预设)
|
|
77
|
+
if (!resolved.all && resolved.targets.length === 1) {
|
|
78
|
+
const found = resolved.targets[0];
|
|
79
|
+
const result = await switchMemberPermission(ctx, found.sessionId, preset);
|
|
80
|
+
if (result.error) return { kind: "error", text: `切换失败:${result.error}` };
|
|
81
|
+
const name = found.member?.name ? `${found.member.name}(${found.sessionId})` : found.sessionId;
|
|
82
|
+
const lines = [`已把成员 ${name} 的权限预设切成 ${result.to}`, `原预设:${result.from ?? "(未记录)"} | 新事件 seq:${result.seq}`];
|
|
83
|
+
if (followedLead) lines.push(`预设来源:跟随主代理 ${followedLead.sessionId}`);
|
|
84
|
+
if (result.note) lines.push(`说明:${result.note}`);
|
|
85
|
+
lines.push(`当前旋钮:sandbox=${result.knobs?.sandbox ?? "(未记录)"} | approval=${result.knobs?.approval ?? "(未记录)"}`);
|
|
86
|
+
const catalog = await readPresetCatalog(ctx);
|
|
87
|
+
if (!catalog.error) lines.push(`可选预设:${catalog.names.join(" / ")}`);
|
|
88
|
+
lines.push("该改动写在会话日志里,成员下次加载/唤醒时按折叠语义生效。");
|
|
89
|
+
return { kind: "success", text: lines.join("\n") };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 全部目标:串行逐个切,回执带名单
|
|
93
|
+
const report = await applyPermissionToMany(ctx, resolved.targets, preset);
|
|
94
|
+
const extra = [];
|
|
95
|
+
if (followedLead) {
|
|
96
|
+
extra.push(`预设来源:跟随主代理 ${followedLead.sessionId}(${followedLead.preset})`);
|
|
97
|
+
for (const w of followedLead.warnings ?? []) extra.push(`提示:${w}`);
|
|
98
|
+
}
|
|
99
|
+
extra.push("批量切换逐条写入各成员会话日志;单个成员失败不影响其余成员。");
|
|
100
|
+
return {
|
|
101
|
+
kind: "success",
|
|
102
|
+
text: renderBatchReport(`批量切换权限预设 → ${preset}`, report, extra),
|
|
103
|
+
};
|
|
104
|
+
} catch (error) {
|
|
105
|
+
return { kind: "error", text: `执行失败:${String(error?.message ?? error)}` };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 注册 `/perm-switch` 指令。
|
|
111
|
+
*
|
|
112
|
+
* @returns {Promise<boolean>} true = 已注册;false = 宿主 commands 服务或 brand 包不可用(已留痕)
|
|
113
|
+
*/
|
|
114
|
+
export async function registerPermSwitchCommand(ctx) {
|
|
115
|
+
const commands = ctx?.get?.("commands");
|
|
116
|
+
if (!commands || typeof commands.register !== "function") {
|
|
117
|
+
log(ctx, "权限指令未注册:ctx.commands 不可用(宿主未挂载 commands 服务)");
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
let CommandDefinitionId = null;
|
|
121
|
+
try {
|
|
122
|
+
const mod = await import("@deepseek-ai/dsh-commands/brand");
|
|
123
|
+
if (typeof mod?.CommandDefinitionId === "function") CommandDefinitionId = mod.CommandDefinitionId;
|
|
124
|
+
} catch { /* 工作区自测环境没有宿主依赖 → 走下面的返回 false */ }
|
|
125
|
+
if (!CommandDefinitionId) {
|
|
126
|
+
log(ctx, "权限指令未注册:拿不到 @deepseek-ai/dsh-commands/brand 的 CommandDefinitionId(缺宿主依赖)");
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
commands.register({
|
|
130
|
+
definitionId: CommandDefinitionId(DEFINITION_ID),
|
|
131
|
+
name: COMMAND_NAME,
|
|
132
|
+
description: "查看或切换成员(子代理)的权限预设;无参 = 列出成员与各自预设",
|
|
133
|
+
input: { hint: "[<成员名|sessionId> [<预设名>]]" },
|
|
134
|
+
recordInput: false,
|
|
135
|
+
handler: (invocation) => handlePermSwitchCommand(ctx, invocation),
|
|
136
|
+
});
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// dsh-session-conductor — 成员(子代理)权限预设的查看与切换
|
|
2
|
+
//
|
|
3
|
+
// 【这个文件解决什么】子代理会话在界面上无法手动切换权限预设(只有主会话能切),
|
|
4
|
+
// 一旦它的 `permission/preset` 指向一个当前不可用的预设,该成员就会永久唤不醒:
|
|
5
|
+
// 宿主 permission-presets 的 pinInitialPermission() 在会话发布前校验
|
|
6
|
+
// if (preset === 'auto' && autoAdmit === undefined) throw new Error('cannot restore preset "auto" without its active integration')
|
|
7
|
+
// 而 subagent 的 coldResume() 把这个异常统一包成 `subagent/not-resumable`,
|
|
8
|
+
// 报错文本完全指不到权限预设这一层。因此需要一条「不依赖界面、直接落到会话日志」的通道。
|
|
9
|
+
//
|
|
10
|
+
// 【为什么是追加事件而不是改历史】权限三个旋钮(preset / sandbox / approval)在宿主里是**折叠**语义,
|
|
11
|
+
// 宿主明确保证「The LAST such event is the session's override」。所以追加一条 `permission/preset`
|
|
12
|
+
// 就能让新值生效,已有事件一条不动、可删尾部帧回滚。
|
|
13
|
+
//
|
|
14
|
+
// 【为什么只写 preset 一条】切换预设时宿主自己会按 spec 补齐 sandbox / approval(见其 apply())。
|
|
15
|
+
// 这里不重复写:若目标预设的 sandbox/approval 与会话现值不同,回执会明确提示「需要宿主侧补齐」,
|
|
16
|
+
// 避免我们自己造出「preset 与 knobs 不一致」的标签失配。
|
|
17
|
+
import { log } from "../../shared/log.js";
|
|
18
|
+
import { findTargetAgent, collectTeamMembers } from "./locate.js";
|
|
19
|
+
|
|
20
|
+
/** 读会话日志路径(按 sessionId 定位,与切换模型链路同一套定位)。 */
|
|
21
|
+
async function locateLog(sessionId) {
|
|
22
|
+
const { findSessionLogById, sessionsRootOf } = await import("../../sessions/events.js");
|
|
23
|
+
const filePath = findSessionLogById(sessionId, sessionsRootOf());
|
|
24
|
+
return filePath ?? null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 折叠读成员当前的权限状态(只看最后一条同类事件,与宿主折叠语义一致)。
|
|
29
|
+
*
|
|
30
|
+
* @param {string} sessionId 成员会话 id
|
|
31
|
+
* @returns {Promise<{preset: string|null, sandbox: string|null, approval: string|null, lastSeq: number, error?: string}>}
|
|
32
|
+
*/
|
|
33
|
+
export async function readMemberPermission(sessionId) {
|
|
34
|
+
const filePath = await locateLog(sessionId);
|
|
35
|
+
if (!filePath) return { preset: null, sandbox: null, approval: null, lastSeq: -1, error: "会话日志不存在" };
|
|
36
|
+
const { readFile } = await import("node:fs/promises");
|
|
37
|
+
const { decodeAllFrames } = await import("../../zstd-frames.js");
|
|
38
|
+
let text;
|
|
39
|
+
try {
|
|
40
|
+
text = await decodeAllFrames(await readFile(filePath));
|
|
41
|
+
} catch (error) {
|
|
42
|
+
return { preset: null, sandbox: null, approval: null, lastSeq: -1, error: `日志解码失败:${String(error?.message ?? error)}` };
|
|
43
|
+
}
|
|
44
|
+
let preset = null;
|
|
45
|
+
let sandbox = null;
|
|
46
|
+
let approval = null;
|
|
47
|
+
let lastSeq = -1;
|
|
48
|
+
for (const line of text.split("\n")) {
|
|
49
|
+
if (line.trim() === "") continue;
|
|
50
|
+
let e;
|
|
51
|
+
try { e = JSON.parse(line); } catch { continue; }
|
|
52
|
+
if (typeof e.seq === "number" && e.seq > lastSeq) lastSeq = e.seq;
|
|
53
|
+
if (e.type === "permission/preset") preset = e.data?.preset ?? null;
|
|
54
|
+
else if (e.type === "sandbox/mode") sandbox = e.data?.mode ?? null;
|
|
55
|
+
else if (e.type === "approval/policy") approval = e.data?.policy ?? null;
|
|
56
|
+
}
|
|
57
|
+
return { preset, sandbox, approval, lastSeq };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 读宿主当前可选的预设目录(用于校验,避免把会话写成宿主无法恢复的值)。
|
|
62
|
+
*
|
|
63
|
+
* @param {object} ctx 插件 context
|
|
64
|
+
* @returns {Promise<{names: string[], defaults: string[], defaultPreset: string|null, specs: Map<string, {sandbox?: string, approval?: string}>} | {error: string}>}
|
|
65
|
+
*/
|
|
66
|
+
export async function readPresetCatalog(ctx) {
|
|
67
|
+
const service = ctx.get?.("permissionPresets") ?? null;
|
|
68
|
+
if (!service || typeof service.catalog !== "function") {
|
|
69
|
+
return { error: "拿不到 permissionPresets.catalog(宿主未挂载该服务),无法校验预设名" };
|
|
70
|
+
}
|
|
71
|
+
let catalog;
|
|
72
|
+
try {
|
|
73
|
+
catalog = await service.catalog();
|
|
74
|
+
} catch (error) {
|
|
75
|
+
return { error: `读取预设目录失败:${String(error?.message ?? error)}` };
|
|
76
|
+
}
|
|
77
|
+
const options = Array.isArray(catalog?.options) ? catalog.options : [];
|
|
78
|
+
const names = options.map((o) => String(o?.name ?? o?.id ?? "")).filter(Boolean);
|
|
79
|
+
const specs = new Map();
|
|
80
|
+
for (const option of options) {
|
|
81
|
+
const name = String(option?.name ?? option?.id ?? "");
|
|
82
|
+
if (!name) continue;
|
|
83
|
+
specs.set(name, { sandbox: option?.sandbox, approval: option?.approval });
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
names,
|
|
87
|
+
defaults: Array.isArray(catalog?.defaultOptions) ? catalog.defaultOptions.map((o) => String(o?.name ?? "")) : [],
|
|
88
|
+
defaultPreset: catalog?.defaultPreset ?? null,
|
|
89
|
+
specs,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 把成员的权限预设切到目标值。
|
|
95
|
+
*
|
|
96
|
+
* 实现:定位会话日志 → 折叠读当前状态 → 校验预设名在宿主目录里 → 追加一条
|
|
97
|
+
* `permission/preset`(独立 zstd 帧、带 checksum、长度 CAS、fsync)→ 写后复核。
|
|
98
|
+
*
|
|
99
|
+
* @param {object} ctx 插件 context
|
|
100
|
+
* @param {string} sessionId 成员会话 id
|
|
101
|
+
* @param {string} preset 目标预设名
|
|
102
|
+
* @returns {Promise<{ok: true, from: string|null, to: string, seq: number, knobs: object, note?: string} | {error: string}>}
|
|
103
|
+
*/
|
|
104
|
+
export async function switchMemberPermission(ctx, sessionId, preset) {
|
|
105
|
+
const target = String(preset ?? "").trim();
|
|
106
|
+
if (!target) return { error: "预设名不能为空" };
|
|
107
|
+
|
|
108
|
+
const catalog = await readPresetCatalog(ctx);
|
|
109
|
+
if (catalog.error) return { error: catalog.error };
|
|
110
|
+
if (!catalog.names.includes(target)) {
|
|
111
|
+
// 明确点出 auto 的特殊性:它只有在「自动审查集成」挂载时才可选,
|
|
112
|
+
// 否则写进去会让该会话下次发布时直接抛错(表现为唤不醒)。
|
|
113
|
+
const hint = target === "auto"
|
|
114
|
+
? ";注意 auto 只有在「自动审查」集成启用时才可用,写入后一旦关闭该功能,该会话就会唤不醒"
|
|
115
|
+
: "";
|
|
116
|
+
return { error: `预设 "${target}" 不在当前可选列表里(可选:${catalog.names.join(" / ")})${hint}` };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const before = await readMemberPermission(sessionId);
|
|
120
|
+
if (before.error) return { error: before.error };
|
|
121
|
+
if (before.preset === target) {
|
|
122
|
+
return { ok: true, from: before.preset, to: target, seq: before.lastSeq, knobs: { sandbox: before.sandbox, approval: before.approval }, note: "已经是该预设,无需重复写入" };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const filePath = await locateLog(sessionId);
|
|
126
|
+
if (!filePath) return { error: "会话日志不存在" };
|
|
127
|
+
const { appendEventFrame } = await import("../../sessions/tail-io.js");
|
|
128
|
+
const written = await appendEventFrame(filePath, {
|
|
129
|
+
type: "permission/preset",
|
|
130
|
+
seq: before.lastSeq + 1,
|
|
131
|
+
time: Date.now(),
|
|
132
|
+
data: { preset: target },
|
|
133
|
+
});
|
|
134
|
+
if (!written.ok) return { error: `写入会话日志失败:${written.error}` };
|
|
135
|
+
|
|
136
|
+
// 写后复核:确认最后一条 preset 已生效(折叠语义下这就是当前值)
|
|
137
|
+
const after = await readMemberPermission(sessionId);
|
|
138
|
+
if (after.error) return { error: `写入后复核失败:${after.error}` };
|
|
139
|
+
if (after.preset !== target) return { error: `写入后复核不一致(期望 ${target},实际 ${after.preset})` };
|
|
140
|
+
|
|
141
|
+
// 预设有配套的 sandbox/approval 时给出提示:我们只写 preset,旋钮由宿主在下次加载时按 spec 补齐
|
|
142
|
+
const spec = catalog.specs.get(target);
|
|
143
|
+
let note;
|
|
144
|
+
if (spec && (spec.sandbox !== undefined || spec.approval !== undefined)) {
|
|
145
|
+
const mismatch = [];
|
|
146
|
+
if (spec.sandbox !== undefined && before.sandbox !== null && spec.sandbox !== before.sandbox) {
|
|
147
|
+
mismatch.push(`sandbox ${before.sandbox} → ${spec.sandbox}`);
|
|
148
|
+
}
|
|
149
|
+
if (spec.approval !== undefined && before.approval !== null && spec.approval !== before.approval) {
|
|
150
|
+
mismatch.push(`approval ${before.approval} → ${spec.approval}`);
|
|
151
|
+
}
|
|
152
|
+
if (mismatch.length > 0) {
|
|
153
|
+
note = `该预设自带 ${mismatch.join("、")};本工具只写 preset 事件,旋钮需宿主加载时补齐(若界面显示与实际不一致,属预期)`;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
log(ctx, `权限切换:${sessionId} preset ${before.preset} → ${target}(seq ${written.seq})`);
|
|
157
|
+
return { ok: true, from: before.preset, to: target, seq: written.seq, knobs: { sandbox: before.sandbox, approval: before.approval }, note };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** 成员标签(有名字就带上,便于人读)。 */
|
|
161
|
+
function memberLabel(found) {
|
|
162
|
+
const name = found?.member?.name;
|
|
163
|
+
return name ? `${name}(${found.sessionId})` : String(found?.sessionId ?? "?");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* 渲染单个成员的权限快照(供工具与指令共用)。
|
|
168
|
+
*
|
|
169
|
+
* @param {object} found findTargetAgent 的返回值
|
|
170
|
+
* @returns {Promise<string[]>} 多行文本
|
|
171
|
+
*/
|
|
172
|
+
export async function describeMemberPermission(found) {
|
|
173
|
+
const state = await readMemberPermission(found.sessionId);
|
|
174
|
+
if (state.error) return [`成员 ${memberLabel(found)}`, ` 读取失败:${state.error}`];
|
|
175
|
+
return [
|
|
176
|
+
`成员 ${memberLabel(found)}`,
|
|
177
|
+
` 权限预设:${state.preset ?? "(未记录)"}`,
|
|
178
|
+
` sandbox:${state.sandbox ?? "(未记录)"} | approval:${state.approval ?? "(未记录)"}`,
|
|
179
|
+
` 末 seq:${state.lastSeq}`,
|
|
180
|
+
];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** 列出全部成员及其权限预设(指令无参形态)。 */
|
|
184
|
+
export async function renderMemberPermissionList(ctx, invocation) {
|
|
185
|
+
const members = collectTeamMembers(ctx, { caller: invocation?.agent ?? null });
|
|
186
|
+
if (members.length === 0) return "没读到任何成员。可能当前不是 Team 成员、名册不可用,或确实还没有队友。";
|
|
187
|
+
const catalog = await readPresetCatalog(ctx);
|
|
188
|
+
const lines = [`成员权限预设(共 ${members.length} 个)`, ""];
|
|
189
|
+
for (const m of members) {
|
|
190
|
+
const state = await readMemberPermission(m.sessionId);
|
|
191
|
+
const name = m.name ? `${m.name}(${m.sessionId})` : m.sessionId;
|
|
192
|
+
const warn = state.preset === "auto" && !catalog.error && !catalog.names.includes("auto")
|
|
193
|
+
? " ⚠️ auto 当前不可用(自动审查未启用),该成员可能唤不醒"
|
|
194
|
+
: "";
|
|
195
|
+
lines.push(` ${name}:${state.preset ?? "(未记录)"}${warn}`);
|
|
196
|
+
}
|
|
197
|
+
lines.push("");
|
|
198
|
+
if (catalog.error) lines.push(`可选预设:${catalog.error}`);
|
|
199
|
+
else lines.push(`可选预设:${catalog.names.join(" / ")}`);
|
|
200
|
+
lines.push("");
|
|
201
|
+
lines.push("切换:/perm-switch <成员名或 sessionId> <预设名>");
|
|
202
|
+
return lines.join("\n");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** 解析「成员 + 预设」两个参数(预设名不含斜杠,直接取剩余文本)。 */
|
|
206
|
+
export function parsePermissionArgs(rawInput) {
|
|
207
|
+
const raw = String(rawInput ?? "").trim();
|
|
208
|
+
if (raw === "") return { parts: [] };
|
|
209
|
+
const parts = raw.split(/\s+/).filter(Boolean);
|
|
210
|
+
return { parts };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export { findTargetAgent };
|
|
File without changes
|
|
@@ -104,6 +104,132 @@ function restoreSwitchLog(backup, filePath) {
|
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
/**
|
|
108
|
+
* 该会话是否带「委派策略快照」,带则返回一条排查提示。
|
|
109
|
+
*
|
|
110
|
+
* 【为什么需要】本机实测:子代理会话开头会有 `approval/policy`(source=delegation,
|
|
111
|
+
* 创建时从父级快照)。若部署侧关掉「自动审查」,宿主恢复这条快照会失败,失败被
|
|
112
|
+
* `manager.coldResume` 统一包成 `subagent/not-resumable` —— 界面只看到
|
|
113
|
+
* 「subagent cannot be resumed」,极具误导性(会让人去查模型/描述符/文件)。
|
|
114
|
+
* 因此切换时若发现该会话带这种快照,就主动提示先去查自动审查开关。
|
|
115
|
+
*
|
|
116
|
+
* 只读首帧(header + 开头几条策略事件),失败一律返回空字符串,绝不影响切换本身。
|
|
117
|
+
*
|
|
118
|
+
* @param {string} filePath 会话日志路径
|
|
119
|
+
* @returns {Promise<string>} 提示文案;不适用或读不到时为空串
|
|
120
|
+
*/
|
|
121
|
+
export async function delegationPolicyHint(filePath) {
|
|
122
|
+
try {
|
|
123
|
+
const { readFile } = await import("node:fs/promises");
|
|
124
|
+
const { scanZstdFrames, decodeFrame } = await import("../../zstd-frames.js");
|
|
125
|
+
const buf = await readFile(filePath);
|
|
126
|
+
const { frames } = scanZstdFrames(buf);
|
|
127
|
+
if (frames.length === 0) return "";
|
|
128
|
+
// 策略事件在会话最开头(header 之后几条),解首帧通常就够
|
|
129
|
+
let text = (await decodeFrame(buf.subarray(frames[0].start, frames[0].end))).toString("utf8");
|
|
130
|
+
if (frames.length > 1) {
|
|
131
|
+
text += (await decodeFrame(buf.subarray(frames[1].start, frames[1].end))).toString("utf8").slice(0, 20000);
|
|
132
|
+
}
|
|
133
|
+
if (text.includes('"type":"approval/policy"') && text.includes('"source":"delegation"')) {
|
|
134
|
+
return '该成员带委派策略快照(approval/policy, source=delegation);若它之后唤不醒并报 subagent/not-resumable,先检查部署侧「自动审查」开关是否被关掉——本机实测这才是真因,与模型/描述符/文件无关';
|
|
135
|
+
}
|
|
136
|
+
return "";
|
|
137
|
+
} catch {
|
|
138
|
+
return "";
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 切换模型时同步修正「失效的 descriptor 路由」。
|
|
144
|
+
*
|
|
145
|
+
* 【为什么必须做】宿主 cold resume 子代理时,provider/model 取自会话日志里的
|
|
146
|
+
* `subagent/descriptor`(manager.coldResume → materialize 的 agentOptions),
|
|
147
|
+
* `model/selection` 不参与该路径。若 descriptor 仍指向一个已被删除的 provider
|
|
148
|
+
* (例如配置里移除了某个渠道),materialize 必然失败并被统一包成
|
|
149
|
+
* `subagent/not-resumable` —— 表现为「这个成员永远唤不醒,怎么切模型都没用」。
|
|
150
|
+
* 因此这里在写入 selection 之后顺带校验 descriptor 的路由是否仍存在,失效就同步改掉。
|
|
151
|
+
*
|
|
152
|
+
* 保守原则:拿不到模型目录(服务不可用)或 descriptor 本就有效时,一律不动日志。
|
|
153
|
+
*
|
|
154
|
+
* @returns {Promise<{repaired?: boolean, skipped?: string, error?: string}>}
|
|
155
|
+
*/
|
|
156
|
+
async function repairDescriptorIfStale(ctx, filePath, sessionId, provider, model) {
|
|
157
|
+
const { readDescriptorEvent } = await import("../../sessions/tail-io.js");
|
|
158
|
+
const { readFile } = await import("node:fs/promises");
|
|
159
|
+
let buf;
|
|
160
|
+
try {
|
|
161
|
+
buf = await readFile(filePath);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
return { error: `读取会话日志失败:${String(error?.message ?? error)}` };
|
|
164
|
+
}
|
|
165
|
+
let found;
|
|
166
|
+
try {
|
|
167
|
+
found = await readDescriptorEvent(buf);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
return { error: `读取 descriptor 失败:${String(error?.message ?? error)}` };
|
|
170
|
+
}
|
|
171
|
+
const descriptor = found.descriptor;
|
|
172
|
+
if (!descriptor || typeof descriptor !== "object") return { skipped: "不是子代理会话(无 descriptor)" };
|
|
173
|
+
|
|
174
|
+
// 取当前可用 provider 列表;拿不到就不猜、不动日志
|
|
175
|
+
const sc = ctx.get("sessionController");
|
|
176
|
+
if (typeof sc?.modelCatalog !== "function") return { skipped: "modelCatalog 不可用,跳过 descriptor 校验" };
|
|
177
|
+
let groups;
|
|
178
|
+
try {
|
|
179
|
+
const catalog = await sc.modelCatalog();
|
|
180
|
+
groups = catalog?.groups ?? [];
|
|
181
|
+
} catch (error) {
|
|
182
|
+
return { skipped: `modelCatalog 调用失败,跳过:${String(error?.message ?? error)}` };
|
|
183
|
+
}
|
|
184
|
+
if (groups.length === 0) return { skipped: "模型目录为空,跳过 descriptor 校验" };
|
|
185
|
+
|
|
186
|
+
const providerIds = new Set(groups.map((g) => g.id));
|
|
187
|
+
const staleProvider = descriptor.agentProvider !== undefined && !providerIds.has(descriptor.agentProvider);
|
|
188
|
+
const staleModel = (() => {
|
|
189
|
+
if (descriptor.agentProvider === undefined || descriptor.agentModel === undefined) return false;
|
|
190
|
+
const group = groups.find((g) => g.id === descriptor.agentProvider);
|
|
191
|
+
if (!group) return false;
|
|
192
|
+
return !(group.models ?? []).some((m) => m.id === descriptor.agentModel);
|
|
193
|
+
})();
|
|
194
|
+
if (!staleProvider && !staleModel) return { skipped: "descriptor 路由有效" };
|
|
195
|
+
|
|
196
|
+
// 整份重写(descriptor 在文件头部,追加帧改不了它);这条路径只在路由失效时走一次,代价可接受
|
|
197
|
+
const { encodeSessionText } = await lazyRepair();
|
|
198
|
+
const { decodeAllFrames } = await import("../../zstd-frames.js");
|
|
199
|
+
const fullText = buf.length > 0 ? await decodeAllFrames(buf) : "";
|
|
200
|
+
const lines = fullText.split("\n").filter((l) => l.trim() !== "");
|
|
201
|
+
if (lines.length === 0) return { error: "会话日志为空,无法修正 descriptor" };
|
|
202
|
+
let patched = 0;
|
|
203
|
+
for (let i = 1; i < lines.length; i += 1) {
|
|
204
|
+
let e;
|
|
205
|
+
try { e = JSON.parse(lines[i]); } catch { continue; }
|
|
206
|
+
if (e.type !== "subagent/descriptor") continue;
|
|
207
|
+
e.data.agentProvider = provider;
|
|
208
|
+
e.data.agentModel = model;
|
|
209
|
+
lines[i] = JSON.stringify(e);
|
|
210
|
+
patched += 1;
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
if (patched === 0) return { skipped: "未找到可改写的 descriptor" };
|
|
214
|
+
|
|
215
|
+
// 写前校验 seq 严格连续,避免把会话写成 corrupt
|
|
216
|
+
let prev = -1, gaps = 0;
|
|
217
|
+
for (let i = 1; i < lines.length; i += 1) {
|
|
218
|
+
let e;
|
|
219
|
+
try { e = JSON.parse(lines[i]); } catch { return { error: "改写后存在坏行,放弃写盘" }; }
|
|
220
|
+
if (typeof e.seq !== "number") continue;
|
|
221
|
+
if (prev >= 0 && e.seq !== prev + 1) gaps += 1;
|
|
222
|
+
prev = e.seq;
|
|
223
|
+
}
|
|
224
|
+
if (gaps > 0) return { error: `改写后 seq 不连续(${gaps} 处),放弃写盘` };
|
|
225
|
+
|
|
226
|
+
const backup = backupSwitchLog(filePath, sessionId);
|
|
227
|
+
if (backup.error) return { error: backup.error };
|
|
228
|
+
replaceFileSync(filePath, await encodeSessionText(lines.join("\n") + "\n"), { tmpSuffix: ".descfixtmp" });
|
|
229
|
+
log(ctx, `切换写日志:descriptor 路由失效(${staleProvider ? "provider 已不存在" : "model 不在目录"}),已改为 ${provider}/${model}`);
|
|
230
|
+
return { repaired: true };
|
|
231
|
+
}
|
|
232
|
+
|
|
107
233
|
export async function appendSelectionEventToLog(ctx, sessionId, provider, model) {
|
|
108
234
|
const startedAt = Date.now();
|
|
109
235
|
// 这条路径会整份解码会话日志再整份写回,而宿主在同一会话上会并发追加事件
|
|
@@ -153,6 +279,17 @@ export async function appendSelectionEventToLog(ctx, sessionId, provider, model)
|
|
|
153
279
|
const written = await appendEventFrame(filePath, event);
|
|
154
280
|
log(ctx, `切换写日志:追加帧 ${Date.now() - writeStartedAt}ms(seq ${event.seq})`);
|
|
155
281
|
if (!written.ok) return { error: `写入会话日志失败:${written.error}` };
|
|
282
|
+
// descriptor 路由失效会让成员永久唤不醒(宿主报 subagent/not-resumable),这里顺带修
|
|
283
|
+
const descFix = await repairDescriptorIfStale(ctx, filePath, sessionId, provider, model);
|
|
284
|
+
if (descFix.error) log(ctx, `切换写日志:descriptor 校验失败(不影响本次切换):${descFix.error}`);
|
|
285
|
+
|
|
286
|
+
// 附带排查提示:本机实测「成员唤不醒」的真凶是部署侧的「自动审查」开关被关掉,
|
|
287
|
+
// 与 descriptor / 帧校验和 / 写锁全都无关(详见 docs/VERSIONS.md 1.3.15)。
|
|
288
|
+
// 判据:该会话日志开头存在 `approval/policy` 且 source 为 delegation(创建时从父级快照),
|
|
289
|
+
// 此时若自动审查被关掉,宿主恢复这条委派快照会失败,且失败被 coldResume 统一包成
|
|
290
|
+
// `subagent/not-resumable` —— 表现就是「切什么模型都唤不醒、重启也没用」。
|
|
291
|
+
const policyHint = await delegationPolicyHint(filePath);
|
|
292
|
+
if (policyHint) log(ctx, `切换写日志:${policyHint}`);
|
|
156
293
|
|
|
157
294
|
// ③ 写后自检:只读尾部,确认新事件确实落在尾部且 seq 严格 +1 衔接
|
|
158
295
|
try {
|