psyclaw 0.27.6 → 0.27.12

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.
Files changed (64) hide show
  1. package/README.md +17 -14
  2. package/dist/apps/panel/index.html +11 -11
  3. package/dist/src/adapters/pi/extension.js +593 -101
  4. package/dist/src/adapters/pi/extension.js.map +1 -1
  5. package/dist/src/agents/import.js +2 -1
  6. package/dist/src/agents/import.js.map +1 -1
  7. package/dist/src/analysis/hooks.d.ts +19 -2
  8. package/dist/src/analysis/hooks.js +48 -7
  9. package/dist/src/analysis/hooks.js.map +1 -1
  10. package/dist/src/branding.d.ts +2 -2
  11. package/dist/src/branding.js +2 -2
  12. package/dist/src/branding.js.map +1 -1
  13. package/dist/src/chat.js +5 -2
  14. package/dist/src/chat.js.map +1 -1
  15. package/dist/src/cli.js +21 -6
  16. package/dist/src/cli.js.map +1 -1
  17. package/dist/src/index.d.ts +2 -0
  18. package/dist/src/index.js +2 -0
  19. package/dist/src/index.js.map +1 -1
  20. package/dist/src/install/installer.js +3 -0
  21. package/dist/src/install/installer.js.map +1 -1
  22. package/dist/src/integrations/mcp-runtime.d.ts +13 -0
  23. package/dist/src/integrations/mcp-runtime.js +48 -0
  24. package/dist/src/integrations/mcp-runtime.js.map +1 -1
  25. package/dist/src/orchestration/runner.js +1 -0
  26. package/dist/src/orchestration/runner.js.map +1 -1
  27. package/dist/src/panel/events.d.ts +2 -1
  28. package/dist/src/panel/events.js +2 -5
  29. package/dist/src/panel/events.js.map +1 -1
  30. package/dist/src/panel/server.js +44 -18
  31. package/dist/src/panel/server.js.map +1 -1
  32. package/dist/src/project/approvals.d.ts +50 -0
  33. package/dist/src/project/approvals.js +62 -0
  34. package/dist/src/project/approvals.js.map +1 -0
  35. package/dist/src/project/bootstrap.d.ts +17 -0
  36. package/dist/src/project/bootstrap.js +87 -1
  37. package/dist/src/project/bootstrap.js.map +1 -1
  38. package/dist/src/project/paths.js +5 -2
  39. package/dist/src/project/paths.js.map +1 -1
  40. package/dist/src/research/ledger.d.ts +3 -2
  41. package/dist/src/research/ledger.js +47 -2
  42. package/dist/src/research/ledger.js.map +1 -1
  43. package/dist/src/skills/user-skills.d.ts +65 -0
  44. package/dist/src/skills/user-skills.js +340 -0
  45. package/dist/src/skills/user-skills.js.map +1 -0
  46. package/dist/src/style/cli-ui.js +19 -30
  47. package/dist/src/style/cli-ui.js.map +1 -1
  48. package/dist/src/telemetry/export.d.ts +8 -6
  49. package/dist/src/telemetry/export.js +81 -18
  50. package/dist/src/telemetry/export.js.map +1 -1
  51. package/dist/src/tui/provider-picker.d.ts +2 -0
  52. package/dist/src/tui/provider-picker.js +10 -0
  53. package/dist/src/tui/provider-picker.js.map +1 -1
  54. package/dist/src/tui/skill-manager.d.ts +3 -0
  55. package/dist/src/tui/skill-manager.js +11 -1
  56. package/dist/src/tui/skill-manager.js.map +1 -1
  57. package/dist/src/updates/update.js +10 -17
  58. package/dist/src/updates/update.js.map +1 -1
  59. package/dist/src/wizard.js +22 -8
  60. package/dist/src/wizard.js.map +1 -1
  61. package/dist/src/workflows/analysis-delegation.js +2 -2
  62. package/dist/src/workflows/analysis-delegation.js.map +1 -1
  63. package/package.json +2 -2
  64. package/scripts/rebrand-pi.mjs +94 -4
@@ -1,20 +1,20 @@
1
1
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
- import { asProject, bootstrapProject, exportTraces, projectPaths, runOfflineBrief, runInstitutionalFulltext, runLiteratureReview, runExpertReview, runAnalysisDelegation, runWritingReview, runMetaAnalysis, createStageRunner, exportAcademicDocument, recordCitationUse, runParallelPeerReview, writeHandoff } from "../../index.js";
3
+ import { appendApproval, approvalInputDigest, approvePrimaryDocument, asProject, bootstrapProject, DEFAULT_RUN_APPROVAL_NODES, exportTraces, PRIMARY_PLAN_DOCUMENTS, primaryPlanApprovalStatus, projectPaths, runOfflineBrief, runInstitutionalFulltext, runLiteratureReview, runExpertReview, runAnalysisDelegation, runWritingReview, runMetaAnalysis, createStageRunner, exportAcademicDocument, recordCitationUse, runParallelPeerReview, writeHandoff } from "../../index.js";
4
4
  import { runPlanWithPi } from "../../orchestration/pi-executor.js";
5
5
  import { atomicWriteFile } from "../../project/jsonl.js";
6
6
  import { RunEventLog } from "../../panel/events.js";
7
7
  import { readProject } from "../../research/ledger.js";
8
8
  import { join } from "node:path";
9
- import { mkdir, readFile } from "node:fs/promises";
10
- import { homedir } from "node:os";
11
- import { PROVIDER_PRESETS, saveProviderConfig } from "../../setup.js";
9
+ import { lstat, mkdir, readFile } from "node:fs/promises";
10
+ import { PROVIDER_PRESETS, providerCredentialSource, saveProviderConfig } from "../../setup.js";
12
11
  import { dirname } from "node:path";
13
- import { fileURLToPath } from "node:url";
12
+ import { fileURLToPath, pathToFileURL } from "node:url";
14
13
  import { coreSkillNames, enabledRecommendedSkillPaths, normalizeRecommendedSkillId, readRecommendationState, readRecommendedCatalog, recommendedSkillTarget, saveRecommendationState, validateModelInstalledRecommendedSkill, } from "../../skills/recommended.js";
15
14
  import { SkillManagerComponent } from "../../tui/skill-manager.js";
16
- import { RuntimeMcpRegistry } from "../../integrations/mcp-runtime.js";
17
- import { ProviderPickerComponent, SecretInputComponent, } from "../../tui/provider-picker.js";
15
+ import { enabledLocalSkillPaths, enabledLocalPromptPaths, readUserSkillState, scanLocalSkills, setLocalSkillEnabled, setLocalSkillsEnabled, skillNamesInPaths, installLocalSkill, userSkillId, } from "../../skills/user-skills.js";
16
+ import { RuntimeMcpRegistry, setUserMcpConfigEnabled, } from "../../integrations/mcp-runtime.js";
17
+ import { SecretInputComponent, } from "../../tui/provider-picker.js";
18
18
  const PARADIGMS = new Set([
19
19
  "survey-observational",
20
20
  "qualitative-thematic",
@@ -47,7 +47,17 @@ function parseInitArgs(args) {
47
47
  async function notifyError(ctx, error) {
48
48
  ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
49
49
  }
50
+ async function runPluginCommand(args) {
51
+ const entry = fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent"));
52
+ const modulePath = join(dirname(entry), "package-manager-cli.js");
53
+ const { handlePackageCommand } = await import(pathToFileURL(modulePath).href);
54
+ const handled = await handlePackageCommand(args);
55
+ if (!handled)
56
+ throw new Error(`不支持的 Plugin 操作:${args[0] ?? ""}`);
57
+ }
50
58
  const activeAgentRuns = new Set();
59
+ const pendingInitApprovals = new Set();
60
+ const activeApprovalDialogs = new Set();
51
61
  const CORE_SKILLS = new Set(["academic-grill", "research-intake", "evidence-capture", "citation-audit", "research-brief"]);
52
62
  function coreSkillPath(name) {
53
63
  return join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "..", "skills", "core", name, "SKILL.md");
@@ -110,8 +120,10 @@ async function saveDefaultModel(provider, model) {
110
120
  await atomicWriteFile(path, `${JSON.stringify({ ...settings, defaultProvider: provider, defaultModel: model }, null, 2)}\n`);
111
121
  }
112
122
  async function pickProviderItem(ctx, title, items) {
113
- const result = await ctx.ui.custom((tui, theme, keybindings, done) => new ProviderPickerComponent(title, items, tui, theme, keybindings, done));
114
- return result.type === "select" ? result.id : undefined;
123
+ const labels = items.map((item) => `${item.current ? "* " : ""}${item.label} (${item.id})`);
124
+ const selected = await ctx.ui.select(title, labels, { timeout: 60_000 });
125
+ const index = selected === undefined ? -1 : labels.indexOf(selected);
126
+ return index < 0 ? undefined : items[index].id;
115
127
  }
116
128
  async function promptProviderKey(ctx, providerName, envName) {
117
129
  const result = await ctx.ui.custom((tui, theme, keybindings, done) => new SecretInputComponent(`配置 ${providerName}`, envName, tui, theme, keybindings, done));
@@ -154,7 +166,7 @@ function academicGrillRequest(subject, mode) {
154
166
  "从最上游、影响最大的未决问题开始。每一轮只问一个问题,同时给出你的推荐答案或推荐决策及主要取舍。",
155
167
  "明确区分探索性与确证性研究,不把规划内容当作已有证据。直到关键分支已经解决或明确交由研究者决定后,再整理研究规格。",
156
168
  mode === "init"
157
- ? "持久化模式:init。追问完成后直接更新 academic-grill Skill 规定的全部项目文档,不再询问是否更新;写入后列出实际更新的文件。"
169
+ ? "持久化模式:init。追问完成后直接更新 academic-grill Skill 规定的全部项目文档,不再询问是否更新;写入后列出实际更新的文件,并把 notes/research-spec.md 与 notes/plan.md 的 Status 统一写为 awaiting-human-approval。"
158
170
  : "持久化模式:review。追问完成后先展示拟更新摘要和受影响文件,并询问我是否写回;得到明确确认前不得修改项目文档。",
159
171
  ].join("\n");
160
172
  }
@@ -165,28 +177,37 @@ async function readControlledRun(root) {
165
177
  try {
166
178
  const value = JSON.parse(await readFile(await controlledRunPath(root), "utf8"));
167
179
  return value.schemaVersion === "psyclaw/controlled-run/v1" && value.status === "active" && typeof value.projectId === "string" && typeof value.objective === "string" && typeof value.activatedAt === "string"
168
- ? value
180
+ ? { ...value, runId: typeof value.runId === "string" ? value.runId : `legacy_${value.projectId}`, mode: value.mode === "auto" ? "auto" : "human", selectedSkills: Array.isArray(value.selectedSkills) ? value.selectedSkills.filter((item) => typeof item === "string") : [] }
169
181
  : null;
170
182
  }
171
183
  catch {
172
184
  return null;
173
185
  }
174
186
  }
175
- async function activateControlledRun(root, projectId, objective) {
187
+ async function activateControlledRun(root, runId, projectId, objective, selectedSkills, mode) {
176
188
  const state = {
177
189
  schemaVersion: "psyclaw/controlled-run/v1",
190
+ runId,
178
191
  projectId,
179
192
  objective,
193
+ selectedSkills: [...new Set(selectedSkills)],
194
+ mode,
180
195
  activatedAt: new Date().toISOString(),
181
196
  status: "active",
182
197
  };
183
198
  await atomicWriteFile(await controlledRunPath(root), `${JSON.stringify(state, null, 2)}\n`);
184
199
  return state;
185
200
  }
186
- function controlledRunRequest(objective) {
201
+ function controlledRunRequest(objective, selectedSkills, mode) {
187
202
  return [
188
- "PsyClaw 受控研究流程已由用户通过 /run 明确启动。先读取 .psyclaw/project.json、.psyclaw/controlled-run.json、notes/research-spec.md 和 notes/plan.md。",
203
+ `PsyClaw ${mode === "auto" ? "自动" : "人在环路"}研究流程已由用户通过 /run 明确启动。先读取 .psyclaw/project.json、.psyclaw/controlled-run.json、.psyclaw/approvals.jsonl、notes/research-spec.md 和 notes/plan.md。`,
189
204
  `本次目标:${objective}`,
205
+ selectedSkills.length > 0
206
+ ? `本次运行由用户选择的优化 Skill:${selectedSkills.join(", ")}。在相关任务中优先加载并遵循这些 Skill;同名 Skill 只加载一次。`
207
+ : "本次运行未指定额外 Skill,使用 PsyClaw 默认研究流程。不要删除、停用或改写已安装的其他 Skill。",
208
+ mode === "auto"
209
+ ? "自动模式:计划内普通步骤连续推进,不再逐项询问;运行时会自动记录审批。原始数据、凭据、破坏性命令、伦理决定、未经核验的事实与外部发布仍必须停止并请求用户决定。"
210
+ : "人在环路模式:每个有副作用或关键方法决策的步骤都必须通过交互审批;拒绝或超时立即停止,不得绕过审批。",
190
211
  "实际推进当前尚未完成的研究任务;先形成或更新符合学术规范的 Markdown 分析报告,不能只描述计划。分析报告必须清楚区分数据来源、统计结果、文献证据、限制与尚未核验内容。",
191
212
  "分析报告完成后,下一步不是直接询问是否导出 DOCX,而是询问用户是否据此撰写论文。",
192
213
  "若用户选择撰写论文,再单独询问是否先进行文献调研,并说明该阶段可能花费较长时间;提示用户可以指定已启用的 Skill,或者选择 PsyClaw 默认的文献调研方式。不要在获得答复前自动开始长时调研。",
@@ -196,6 +217,129 @@ function controlledRunRequest(objective) {
196
217
  "如果用户在未完成文献调研、全文写作或评审前要求导出 DOCX,允许导出当前分析报告,但必须明确标为遵循学术规范的分析报告,不得称为论文。",
197
218
  ].join("\n");
198
219
  }
220
+ function parseRunArgs(args) {
221
+ const value = args.trim();
222
+ const auto = value === "auto" || value.startsWith("auto ");
223
+ const rest = auto ? value.slice(4).trim() : value;
224
+ if (!rest.startsWith("--skills"))
225
+ return { objective: rest, mode: auto ? "auto" : "human" };
226
+ const match = rest.match(/^--skills(?:=|\s+)([^\s]+)(?:\s+([\s\S]*))?$/);
227
+ if (!match)
228
+ throw new Error("Usage: /run [auto] [--skills skill-a,skill-b] [objective]");
229
+ return {
230
+ objective: (match[2] ?? "").trim(),
231
+ requestedSkills: [...new Set(match[1].split(",").map((item) => item.trim()).filter(Boolean))],
232
+ mode: auto ? "auto" : "human",
233
+ };
234
+ }
235
+ async function planDocumentsReady(root) {
236
+ try {
237
+ const [spec, plan] = await Promise.all([
238
+ readFile(join(root, "notes", "research-spec.md"), "utf8"),
239
+ readFile(join(root, "notes", "plan.md"), "utf8"),
240
+ ]);
241
+ return /Status:\s*awaiting-human-approval/i.test(spec) && /Status:\s*awaiting-human-approval/i.test(plan);
242
+ }
243
+ catch {
244
+ return false;
245
+ }
246
+ }
247
+ async function reviewPrimaryDocuments(ctx) {
248
+ if (!ctx.hasUI || activeApprovalDialogs.has(ctx.cwd))
249
+ return false;
250
+ if (!(await planDocumentsReady(ctx.cwd))) {
251
+ ctx.ui.notify("主要计划文档尚未生成完成;请先完成 /init 的学术追问。", "warning");
252
+ return false;
253
+ }
254
+ activeApprovalDialogs.add(ctx.cwd);
255
+ try {
256
+ for (const document of PRIMARY_PLAN_DOCUMENTS) {
257
+ const choice = await ctx.ui.select(`审批${document.title}:${document.path}`, ["批准当前版本", "拒绝并返回修改"], { timeout: 120_000 });
258
+ const approved = choice === "批准当前版本";
259
+ await approvePrimaryDocument(ctx.cwd, document, approved ? "approved" : "rejected");
260
+ if (!approved) {
261
+ ctx.ui.notify(`${document.title}未批准;修改后运行 /approve 重新审批。`, "warning");
262
+ return false;
263
+ }
264
+ }
265
+ ctx.ui.notify("主要计划文档已批准。文档内容变化后审批会自动失效。", "info");
266
+ return true;
267
+ }
268
+ finally {
269
+ activeApprovalDialogs.delete(ctx.cwd);
270
+ }
271
+ }
272
+ async function approveRunNodes(ctx, runId, mode) {
273
+ for (const node of DEFAULT_RUN_APPROVAL_NODES) {
274
+ if (mode === "auto") {
275
+ await appendApproval(ctx.cwd, { kind: "step", nodeId: node.id, decision: "auto-approved", actor: "auto", runId, summary: node.summary });
276
+ continue;
277
+ }
278
+ const options = node.required ? ["批准", "拒绝并停止"] : ["批准", "本次跳过", "拒绝并停止"];
279
+ const choice = await ctx.ui.select(`${node.title}\n${node.summary}`, options, { timeout: 120_000 });
280
+ const decision = choice === "批准" ? "approved" : choice === "本次跳过" ? "skipped" : "rejected";
281
+ await appendApproval(ctx.cwd, { kind: "step", nodeId: node.id, decision, actor: "human", runId, summary: node.summary });
282
+ if (decision === "rejected" || (node.required && decision !== "approved"))
283
+ return false;
284
+ }
285
+ return true;
286
+ }
287
+ function toolApprovalSummary(toolName, input) {
288
+ const record = input && typeof input === "object" && !Array.isArray(input) ? input : {};
289
+ const target = [record.path, record.file_path, record.command, record.action, record.server, record.tool]
290
+ .find((value) => typeof value === "string");
291
+ return `${toolName}${target ? `: ${target.slice(0, 240)}` : ""}`;
292
+ }
293
+ function toolNeedsApproval(toolName, input) {
294
+ if (["write", "edit", "bash", "powershell", "psyclaw_workbench", "psyclaw_mcp"].includes(toolName))
295
+ return true;
296
+ const action = input && typeof input === "object" && !Array.isArray(input) ? input.action : undefined;
297
+ return typeof action === "string" && /write|save|publish|install|download|execute|call/i.test(action);
298
+ }
299
+ function autoApprovalBlocked(toolName, input) {
300
+ const text = JSON.stringify(input ?? {}).toLocaleLowerCase();
301
+ if (/data[\\/]raw|credential|secret|auth\.json|api[_ -]?key/.test(text))
302
+ return "自动模式不能批准原始数据或凭据路径操作";
303
+ if (["bash", "powershell"].includes(toolName) && /\b(rm|rmdir|del|remove-item|format|shutdown|reboot|git\s+reset\s+--hard)\b/i.test(text)) {
304
+ return "自动模式不能批准破坏性命令";
305
+ }
306
+ return undefined;
307
+ }
308
+ async function selectableRunSkills(root) {
309
+ const state = await readRecommendationState(root);
310
+ const rows = await skillManagerRows(root, state);
311
+ const available = [
312
+ ...coreSkillNames().map((name) => ({ id: name, name })),
313
+ ...rows.filter((row) => row.installed && row.enabled && !row.blocked).map((row) => ({ id: row.id, name: row.name })),
314
+ ];
315
+ const seen = new Set();
316
+ return available.filter((skill) => {
317
+ const key = skill.name.trim().toLocaleLowerCase();
318
+ if (!key || seen.has(key))
319
+ return false;
320
+ seen.add(key);
321
+ return true;
322
+ });
323
+ }
324
+ function resolveRunSkills(requested, available) {
325
+ const lookup = new Map();
326
+ for (const skill of available) {
327
+ lookup.set(skill.id.toLocaleLowerCase(), skill.name);
328
+ lookup.set(skill.name.toLocaleLowerCase(), skill.name);
329
+ }
330
+ const selected = [];
331
+ const unknown = [];
332
+ for (const value of requested) {
333
+ const name = lookup.get(value.toLocaleLowerCase());
334
+ if (!name)
335
+ unknown.push(value);
336
+ else if (!selected.some((item) => item.toLocaleLowerCase() === name.toLocaleLowerCase()))
337
+ selected.push(name);
338
+ }
339
+ if (unknown.length > 0)
340
+ throw new Error(`本次运行不可用的 Skill:${unknown.join(", ")}。请先通过 /skill 安装或启用。`);
341
+ return selected;
342
+ }
199
343
  async function recommendedItems(kind) {
200
344
  const file = kind === "skills" ? "catalog.json" : "mcp-catalog.json";
201
345
  const moduleDir = dirname(fileURLToPath(import.meta.url));
@@ -219,11 +363,13 @@ async function recommendedItems(kind) {
219
363
  return { items: [], externalTools: [], installPrep: [] };
220
364
  }
221
365
  function skillScopeLabel(scope) {
366
+ if (scope === "local")
367
+ return "用户本地目录(所有项目可发现)";
222
368
  return scope === "user" ? "系统目录(所有项目)" : "项目目录(仅当前项目)";
223
369
  }
224
370
  async function skillManagerRows(root, state) {
225
371
  const catalog = await readRecommendedCatalog();
226
- return Promise.all(catalog.items.filter((item) => item.kind === "skill").map(async (item) => {
372
+ const recommendedRows = await Promise.all(catalog.items.filter((item) => item.kind === "skill").map(async (item) => {
227
373
  const id = normalizeRecommendedSkillId(item.id);
228
374
  const scope = state.skillScopes?.[id] ?? "project";
229
375
  try {
@@ -239,9 +385,21 @@ async function skillManagerRows(root, state) {
239
385
  enabled: state.skills.includes(id),
240
386
  scope,
241
387
  blocked: false,
388
+ source: "recommended",
242
389
  };
243
390
  }
244
- catch {
391
+ catch (validationError) {
392
+ // A managed target that exists on disk but fails validation (e.g. a URL
393
+ // install whose manifest/hash does not match the pinned catalog) is still
394
+ // shown as installed so the user sees it; enabling it only takes effect
395
+ // once the install is repaired through /install.
396
+ let onDisk = false;
397
+ try {
398
+ const target = recommendedSkillTarget(root, id, scope);
399
+ const stat = await lstat(target);
400
+ onDisk = stat.isDirectory();
401
+ }
402
+ catch { /* target missing */ }
245
403
  return {
246
404
  id,
247
405
  name: item.name,
@@ -249,20 +407,50 @@ async function skillManagerRows(root, state) {
249
407
  ...(item.sourceRef === undefined ? {} : { sourceRef: item.sourceRef }),
250
408
  ...(typeof item.installHint === "string" ? { installHint: item.installHint } : {}),
251
409
  collection: item.skillLayout === "collection",
252
- installed: false,
253
- enabled: false,
410
+ installed: onDisk,
411
+ enabled: onDisk && state.skills.includes(id),
254
412
  scope,
255
413
  blocked: typeof item.sourceRef !== "string" || !/^https:\/\//.test(item.sourceRef),
256
- reason: typeof item.sourceRef === "string" && /^https:\/\//.test(item.sourceRef)
257
- ? "尚未安装;按 Enter 选择安装范围并交给当前模型处理"
258
- : "没有可交给模型检查的来源网址",
414
+ reason: onDisk
415
+ ? `已检测到目录但未通过来源/许可/哈希校验${state.skills.includes(id) ? "(当前配置为启用,但校验失败不会加载)" : ""};可按 Enter 重新安装修复`
416
+ : typeof item.sourceRef === "string" && /^https:\/\//.test(item.sourceRef)
417
+ ? "尚未安装;按 Enter 选择安装范围并交给当前模型处理"
418
+ : "没有可交给模型检查的来源网址",
419
+ source: "recommended",
259
420
  };
260
421
  }
261
422
  }));
423
+ // Merge user-installed local skills from the expanded discovery roots so the
424
+ // management page shows them and lets the user toggle each one. Managed
425
+ // recommended installs are excluded here — they are shown through their
426
+ // recommended rows above (including on-disk-but-unvalidated installs).
427
+ const userState = await readUserSkillState(root);
428
+ const disabled = new Set(userState.disabled);
429
+ const localSkills = await scanLocalSkills(root, { includeManaged: false });
430
+ const localRows = localSkills.map((skill) => ({
431
+ id: userSkillId(skill.name),
432
+ name: skill.name,
433
+ description: skill.description,
434
+ sourceRef: skill.path,
435
+ collection: false,
436
+ installed: true,
437
+ enabled: !disabled.has(skill.name),
438
+ scope: "local",
439
+ blocked: false,
440
+ source: "local",
441
+ duplicatePaths: skill.duplicatePaths,
442
+ }));
443
+ // A catalog skill that is actually installed is shown once through its
444
+ // recommended row (which carries source/trust info); purely local skills —
445
+ // including a local copy of a catalog skill that failed managed validation —
446
+ // are appended so they stay visible and toggleable.
447
+ const installedRecommendedNames = new Set(recommendedRows.filter((row) => row.installed).map((row) => row.name.toLocaleLowerCase()));
448
+ const uniqueLocalRows = localRows.filter((row) => !installedRecommendedNames.has(row.name.toLocaleLowerCase()));
449
+ return [...recommendedRows, ...uniqueLocalRows];
262
450
  }
263
- async function mcpManagerRows(state) {
451
+ async function mcpManagerRows(root, state, runtime) {
264
452
  const { items, installPrep } = await recommendedItems("mcp");
265
- return items.map((item) => {
453
+ const recommendedRows = items.map((item) => {
266
454
  const id = String(item.id ?? "");
267
455
  const plan = installPrep.find((candidate) => candidate.id === id);
268
456
  const dependencies = Array.isArray(plan?.dependencies)
@@ -274,6 +462,7 @@ async function mcpManagerRows(state) {
274
462
  description: String(item.description ?? ""),
275
463
  ...(typeof item.sourceRef === "string" ? { sourceRef: item.sourceRef } : {}),
276
464
  enabled: state.mcp.includes(id),
465
+ source: "recommended",
277
466
  details: [
278
467
  `传输:${String(item.transport ?? "未声明")} · 风险:${String(item.risk ?? "未声明")}`,
279
468
  `参考版本:${String(plan?.ref ?? "由模型读取来源确定")} · 许可证:${String(plan?.license ?? "由模型读取来源确定")}`,
@@ -281,6 +470,31 @@ async function mcpManagerRows(state) {
281
470
  ],
282
471
  };
283
472
  });
473
+ // Merge user-configured MCP servers (`.psyclaw/mcp/*.json` and
474
+ // `~/.psyclaw/mcp/*.json`) so the management page shows them too, even when
475
+ // disabled. User rows win over a same-id recommended row.
476
+ const userRows = (await runtime.listUserConfigs(root)).map((entry) => ({
477
+ id: entry.id,
478
+ name: entry.name,
479
+ description: `用户配置:${entry.command}`,
480
+ enabled: entry.enabled,
481
+ source: "user",
482
+ userEntry: entry,
483
+ details: [
484
+ `传输:stdio · 范围:${entry.scope === "user" ? "用户级" : "项目级"}`,
485
+ `配置:${entry.path}`,
486
+ ],
487
+ }));
488
+ const merged = new Map(recommendedRows.map((row) => [row.id, row]));
489
+ for (const row of userRows)
490
+ merged.set(row.id, row);
491
+ return [...merged.values()].sort((left, right) => {
492
+ if (left.source === "user" && right.source !== "user")
493
+ return -1;
494
+ if (right.source === "user" && left.source !== "user")
495
+ return 1;
496
+ return left.id.localeCompare(right.id);
497
+ });
284
498
  }
285
499
  function skillRowLabel(row) {
286
500
  const state = row.blocked ? "× 阻断" : row.enabled ? "● 已启用" : row.installed ? "○ 未启用" : "↓ 未安装";
@@ -316,7 +530,12 @@ function skillManagerItems(rows) {
316
530
  description: row.description,
317
531
  status: row.blocked ? "blocked" : row.enabled ? "enabled" : row.installed ? "disabled" : "missing",
318
532
  ...(row.sourceRef === undefined ? {} : { sourceRef: row.sourceRef }),
319
- details: [`安装位置:${skillScopeLabel(row.scope)}`],
533
+ details: [
534
+ `安装位置:${skillScopeLabel(row.scope)}`,
535
+ ...(row.duplicatePaths && row.duplicatePaths.length > 0
536
+ ? [`已忽略 ${row.duplicatePaths.length} 个同名来源`]
537
+ : []),
538
+ ],
320
539
  ...(row.reason === undefined ? {} : { reason: row.reason }),
321
540
  })),
322
541
  ];
@@ -339,18 +558,14 @@ async function openMcpManager(ctx, rows) {
339
558
  return ctx.ui.custom((tui, theme, keybindings, done) => (new SkillManagerComponent(mcpManagerItems(rows), tui, theme, keybindings, done, {
340
559
  title: "MCP 管理",
341
560
  itemLabel: "MCP",
342
- footer: "↑/↓ 移动 · Space 开启/关闭项目配置 · Enter 交给模型安装并配置 · Esc 关闭",
561
+ footer: "↑/↓ 移动 · Space 开启/关闭 · a 全部开启 · d 全部关闭 · Enter 交给模型安装并配置 · Esc 关闭",
343
562
  enterAction: "install",
344
- enabledText: "项目配置已开启;Enter 可让当前模型重新安装或修复配置",
345
- disabledText: "项目配置已关闭",
563
+ enabledText: "已开启;Enter 可让当前模型重新安装或修复推荐配置",
564
+ disabledText: "已关闭",
346
565
  })));
347
566
  }
348
567
  async function setRecommendedMcpEnabled(root, id, enabled) {
349
568
  const state = await readRecommendationState(root);
350
- const rows = await mcpManagerRows(state);
351
- const row = rows.find((candidate) => candidate.id === id);
352
- if (!row)
353
- throw new Error(`未找到推荐 MCP: ${id}`);
354
569
  const current = new Set(state.mcp);
355
570
  if (enabled)
356
571
  current.add(id);
@@ -359,6 +574,9 @@ async function setRecommendedMcpEnabled(root, id, enabled) {
359
574
  state.mcp = [...current];
360
575
  await saveRecommendationState(root, state);
361
576
  }
577
+ async function setUserMcpEnabled(entry, enabled) {
578
+ await setUserMcpConfigEnabled(entry, enabled);
579
+ }
362
580
  function modelMcpInstallTask(root, row, plan) {
363
581
  const suggestedCommand = typeof plan?.command === "string" ? plan.command : "请根据来源仓库的最新安装说明确定";
364
582
  const suggestedTarget = typeof plan?.target === "string" ? plan.target : `.psyclaw/mcp/${row.id}.json`;
@@ -381,48 +599,86 @@ async function queueModelMcpInstall(pi, ctx, row, plan) {
381
599
  pi.sendUserMessage(modelMcpInstallTask(ctx.cwd, row, plan), ctx.isIdle() ? {} : { deliverAs: "followUp" });
382
600
  ctx.ui.notify(`已将 ${row.name} 的下载、安装和配置任务交给当前模型。模型完成并确认可启动后,请执行 /reload。`, "info");
383
601
  }
384
- async function showMcpManager(pi, args, ctx) {
602
+ async function showMcpManager(pi, args, ctx, runtime) {
385
603
  const [verb, id] = args.trim().split(/\s+/).filter(Boolean);
386
- if (verb && !["status", "enable", "disable", "install"].includes(verb)) {
387
- throw new Error("Usage: /mcp [status|enable <id>|disable <id>|install <id>]");
604
+ if (verb && !["status", "enable", "disable", "enable-all", "disable-all", "install"].includes(verb)) {
605
+ throw new Error("Usage: /mcp [status|enable <id>|disable <id>|enable-all|disable-all|install <id>]");
388
606
  }
389
607
  if ((verb === "enable" || verb === "disable" || verb === "install") && !id) {
390
608
  throw new Error(`Usage: /mcp ${verb} <id>`);
391
609
  }
392
610
  const catalog = await recommendedItems("mcp");
393
611
  if (verb === "install") {
394
- const rows = await mcpManagerRows(await readRecommendationState(ctx.cwd));
612
+ const rows = await mcpManagerRows(ctx.cwd, await readRecommendationState(ctx.cwd), runtime);
395
613
  const row = rows.find((candidate) => candidate.id === id);
396
614
  if (!row)
397
615
  throw new Error(`未找到推荐 MCP: ${id}`);
616
+ if (row.source !== "recommended")
617
+ throw new Error(`MCP ${id} 已是用户配置,无需再次安装`);
398
618
  await queueModelMcpInstall(pi, ctx, row, catalog.installPrep.find((candidate) => candidate.id === row.id));
399
619
  return;
400
620
  }
621
+ if (verb === "enable-all" || verb === "disable-all") {
622
+ const enabled = verb === "enable-all";
623
+ const rows = await mcpManagerRows(ctx.cwd, await readRecommendationState(ctx.cwd), runtime);
624
+ for (const row of rows) {
625
+ if (row.source === "user" && row.userEntry)
626
+ await setUserMcpEnabled(row.userEntry, enabled);
627
+ else
628
+ await setRecommendedMcpEnabled(ctx.cwd, row.id, enabled);
629
+ }
630
+ ctx.ui.notify(`已批量${enabled ? "开启" : "关闭"}全部 MCP 配置;重启后重新检查运行时可用性`, "info");
631
+ return;
632
+ }
401
633
  if (verb === "enable" || verb === "disable") {
402
- await setRecommendedMcpEnabled(ctx.cwd, id, verb === "enable");
403
- ctx.ui.notify(`MCP ${id} 的项目配置已${verb === "enable" ? "开启" : "关闭"};重启后重新检查安装、信任和工具策略`, "info");
634
+ const rows = await mcpManagerRows(ctx.cwd, await readRecommendationState(ctx.cwd), runtime);
635
+ const row = rows.find((candidate) => candidate.id === id);
636
+ if (!row)
637
+ throw new Error(`未找到 MCP: ${id}`);
638
+ if (row.source === "user" && row.userEntry)
639
+ await setUserMcpEnabled(row.userEntry, verb === "enable");
640
+ else
641
+ await setRecommendedMcpEnabled(ctx.cwd, row.id, verb === "enable");
642
+ ctx.ui.notify(`MCP ${id} 已${verb === "enable" ? "开启" : "关闭"};重启后重新检查安装、信任和工具策略`, "info");
404
643
  return;
405
644
  }
406
645
  if (!ctx.hasUI || typeof ctx.ui.custom !== "function" || verb === "status") {
407
- const rows = await mcpManagerRows(await readRecommendationState(ctx.cwd));
408
- ctx.ui.notify(rows.map((row) => `${row.enabled ? "[on]" : "[off]"} ${row.id} — ${row.name}`).join("\n"), "info");
646
+ const rows = await mcpManagerRows(ctx.cwd, await readRecommendationState(ctx.cwd), runtime);
647
+ ctx.ui.notify(rows.map((row) => `${row.enabled ? "[on]" : "[off]"} ${row.id} — ${row.name}${row.source === "user" ? "(用户配置)" : ""}`).join("\n"), "info");
409
648
  return;
410
649
  }
411
650
  while (true) {
412
- const rows = await mcpManagerRows(await readRecommendationState(ctx.cwd));
651
+ const rows = await mcpManagerRows(ctx.cwd, await readRecommendationState(ctx.cwd), runtime);
413
652
  const action = await openMcpManager(ctx, rows);
414
653
  if (action.type === "close")
415
654
  return;
655
+ if (action.type === "toggle-all") {
656
+ for (const row of rows) {
657
+ if (row.source === "user" && row.userEntry)
658
+ await setUserMcpEnabled(row.userEntry, action.enabled);
659
+ else
660
+ await setRecommendedMcpEnabled(ctx.cwd, row.id, action.enabled);
661
+ }
662
+ ctx.ui.notify(`已批量${action.enabled ? "开启" : "关闭"}全部 MCP 配置;重启后重新检查运行时可用性`, "info");
663
+ continue;
664
+ }
416
665
  const row = rows.find((candidate) => candidate.id === action.id);
417
666
  if (!row)
418
667
  continue;
419
668
  if (action.type === "install") {
669
+ if (row.source !== "recommended") {
670
+ ctx.ui.notify(`${row.name} 已是用户配置,无需再次安装`, "info");
671
+ continue;
672
+ }
420
673
  await queueModelMcpInstall(pi, ctx, row, catalog.installPrep.find((candidate) => candidate.id === row.id));
421
674
  return;
422
675
  }
423
676
  if (action.type === "toggle") {
424
- await setRecommendedMcpEnabled(ctx.cwd, row.id, action.enabled);
425
- ctx.ui.notify(`${row.name} 的项目配置已${action.enabled ? "开启" : "关闭"};重启后重新检查运行时可用性`, "info");
677
+ if (row.source === "user" && row.userEntry)
678
+ await setUserMcpEnabled(row.userEntry, action.enabled);
679
+ else
680
+ await setRecommendedMcpEnabled(ctx.cwd, row.id, action.enabled);
681
+ ctx.ui.notify(`${row.name} 已${action.enabled ? "开启" : "关闭"};重启后重新检查运行时可用性`, "info");
426
682
  }
427
683
  }
428
684
  }
@@ -434,12 +690,12 @@ function modelSkillInstallTask(root, row, scope) {
434
690
  ...(row.installHint ? [`仓库入口提示:${row.installHint}`] : []),
435
691
  `安装位置:${skillScopeLabel(scope)}。`,
436
692
  `唯一允许的最终目标目录:${target}`,
437
- "用户已通过 Skill 管理页授权本次安装。请使用当前会话的联网、文件和命令工具读取来源仓库,并直接下载、安装所需依赖和完成 Skill 安装;不要再次要求安装权限。不要写入其他 Skill 目录,不要修改 data/raw、.git 或研究产物。",
693
+ "用户已通过 Skill 管理页授权本次安装。请使用当前会话的联网、文件和命令工具读取来源仓库,并直接下载、安装所需依赖和完成 Skill 安装;不要再次要求安装权限。不要写入其他 Skill 目录,不要修改 .psyclaw/data/raw、data/raw、.git 或研究产物。",
438
694
  row.collection
439
695
  ? "这是多 Skill 套件:目标目录自身无需 SKILL.md,但其子目录必须包含一个或多个有效 SKILL.md。保留套件内共享目录和相对路径,不得包含 .git、符号链接或凭据。"
440
696
  : "目标目录最终必须直接包含有效 SKILL.md(YAML frontmatter 至少包含 name 和 description),不得包含 .git、符号链接、凭据或二进制大文件。",
441
697
  "如果仓库包含多个 Skill,只安装与此推荐项相符的部分;如果它不是 Skill 或无法合理适配,停止并说明原因,不要伪造 SKILL.md。",
442
- "安装完成后检查目标目录结构,并提醒用户执行 /skills 启用该项,再执行 /reload。",
698
+ "安装完成后检查目标目录结构,并提醒用户执行 /skill 启用该项,再执行 /reload。",
443
699
  ].join("\n");
444
700
  }
445
701
  async function chooseSkillScope(ctx) {
@@ -465,17 +721,17 @@ async function queueModelSkillInstall(pi, ctx, row) {
465
721
  state.skillScopes = { ...(state.skillScopes ?? {}), [row.id]: scope };
466
722
  await saveRecommendationState(ctx.cwd, state);
467
723
  pi.sendUserMessage(modelSkillInstallTask(ctx.cwd, row, scope), ctx.isIdle() ? {} : { deliverAs: "followUp" });
468
- ctx.ui.notify(`已将 ${row.name} 的安装任务交给当前模型,目标为${skillScopeLabel(scope)}。安装完成后请在 /skills 中启用,再执行 /reload。`, "info");
724
+ ctx.ui.notify(`已将 ${row.name} 的安装任务交给当前模型,目标为${skillScopeLabel(scope)}。安装完成后请在 /skill 中启用,再执行 /reload。`, "info");
469
725
  }
470
726
  async function showSkillManager(pi, args, ctx) {
471
727
  const action = args.trim().split(/\s+/).filter(Boolean);
472
728
  const verb = action[0];
473
729
  const id = action[1];
474
- if (verb && !["status", "enable", "disable", "install"].includes(verb)) {
475
- throw new Error("Usage: /skills [status|enable <id>|disable <id>|install <id>]");
730
+ if (verb && !["status", "enable", "disable", "enable-all", "disable-all", "install"].includes(verb)) {
731
+ throw new Error("Usage: /skill [status|enable <id>|disable <id>|enable-all|disable-all]");
476
732
  }
477
733
  if ((verb === "enable" || verb === "disable" || verb === "install") && !id) {
478
- throw new Error(`Usage: /skills ${verb} <id>`);
734
+ throw new Error(`Usage: /skill ${verb} <id>`);
479
735
  }
480
736
  if (verb === "install") {
481
737
  const row = (await skillManagerRows(ctx.cwd, await readRecommendationState(ctx.cwd))).find((candidate) => candidate.id === normalizeRecommendedSkillId(id));
@@ -484,9 +740,44 @@ async function showSkillManager(pi, args, ctx) {
484
740
  await queueModelSkillInstall(pi, ctx, row);
485
741
  return;
486
742
  }
743
+ if (verb === "enable-all" || verb === "disable-all") {
744
+ const enabled = verb === "enable-all";
745
+ const state = await readRecommendationState(ctx.cwd);
746
+ const rows = await skillManagerRows(ctx.cwd, state);
747
+ const managed = rows.filter((row) => row.source === "recommended" && row.installed && !row.blocked);
748
+ const locals = rows.filter((row) => row.source === "local");
749
+ const failed = [];
750
+ for (const row of managed) {
751
+ try {
752
+ await setRecommendedSkillEnabled(ctx.cwd, row.id, enabled);
753
+ }
754
+ catch {
755
+ if (enabled)
756
+ failed.push(row.name);
757
+ }
758
+ }
759
+ if (locals.length > 0) {
760
+ await setLocalSkillsEnabled(ctx.cwd, locals.map((row) => row.name), enabled);
761
+ }
762
+ ctx.ui.notify(enabled && failed.length > 0
763
+ ? `已批量启用(${failed.join("、")} 启用失败)。请运行 /reload 重新加载。`
764
+ : `已批量${enabled ? "启用" : "停用"}可管理的 Skill。请运行 /reload 重新加载。`, enabled && failed.length > 0 ? "warning" : "info");
765
+ return;
766
+ }
487
767
  if (verb === "enable" || verb === "disable") {
488
- await setRecommendedSkillEnabled(ctx.cwd, id, verb === "enable");
489
- ctx.ui.notify(`${verb === "enable" ? "已启用" : "已停用"} ${normalizeRecommendedSkillId(id)}。请运行 /reload 重新加载。`, "info");
768
+ const requested = normalizeRecommendedSkillId(id);
769
+ const state = await readRecommendationState(ctx.cwd);
770
+ const rows = await skillManagerRows(ctx.cwd, state);
771
+ const row = rows.find((candidate) => candidate.id === requested || candidate.name === requested || candidate.id === userSkillId(requested));
772
+ if (!row)
773
+ throw new Error(`未找到 Skill: ${id}`);
774
+ if (row.source === "local") {
775
+ await setLocalSkillEnabled(ctx.cwd, row.name, verb === "enable");
776
+ }
777
+ else {
778
+ await setRecommendedSkillEnabled(ctx.cwd, row.id, verb === "enable");
779
+ }
780
+ ctx.ui.notify(`${verb === "enable" ? "已启用" : "已停用"} ${row.name}。请运行 /reload 重新加载。`, "info");
490
781
  return;
491
782
  }
492
783
  if (!ctx.hasUI || typeof ctx.ui.custom !== "function" || verb === "status") {
@@ -505,6 +796,42 @@ async function showSkillManager(pi, args, ctx) {
505
796
  const action = await openSkillManager(ctx, rows);
506
797
  if (action.type === "close")
507
798
  return;
799
+ if (action.type === "toggle-all") {
800
+ const managed = rows.filter((row) => row.source === "recommended" && row.installed && !row.blocked);
801
+ const locals = rows.filter((row) => row.source === "local");
802
+ if (action.enabled) {
803
+ const failed = [];
804
+ for (const row of managed) {
805
+ try {
806
+ await setRecommendedSkillEnabled(ctx.cwd, row.id, true);
807
+ }
808
+ catch {
809
+ failed.push(row.name);
810
+ }
811
+ }
812
+ if (locals.length > 0) {
813
+ const ids = locals.map((row) => row.name);
814
+ await setLocalSkillsEnabled(ctx.cwd, ids, true);
815
+ }
816
+ ctx.ui.notify(failed.length > 0
817
+ ? `已启用全部可管理的 Skill(${failed.join("、")} 启用失败:可能未通过来源、许可或依赖预检)。请运行 /reload 重新加载。`
818
+ : `已启用全部可管理的 Skill。请运行 /reload 重新加载。`, failed.length > 0 ? "warning" : "info");
819
+ }
820
+ else {
821
+ for (const row of managed) {
822
+ try {
823
+ await setRecommendedSkillEnabled(ctx.cwd, row.id, false);
824
+ }
825
+ catch { /* already disabled */ }
826
+ }
827
+ if (locals.length > 0) {
828
+ const ids = locals.map((row) => row.name);
829
+ await setLocalSkillsEnabled(ctx.cwd, ids, false);
830
+ }
831
+ ctx.ui.notify("已停用全部可管理的 Skill。请运行 /reload 重新加载。", "info");
832
+ }
833
+ continue;
834
+ }
508
835
  const row = rows.find((candidate) => candidate.id === action.id);
509
836
  if (!row)
510
837
  continue;
@@ -513,8 +840,14 @@ async function showSkillManager(pi, args, ctx) {
513
840
  return;
514
841
  }
515
842
  if (action.type === "toggle") {
516
- await setRecommendedSkillEnabled(ctx.cwd, row.id, action.enabled);
517
- ctx.ui.notify(`已${action.enabled ? "启用" : "停用"} ${row.name}。请运行 /reload 重新加载。`, "info");
843
+ if (row.source === "local") {
844
+ await setLocalSkillEnabled(ctx.cwd, row.name, action.enabled);
845
+ ctx.ui.notify(`已${action.enabled ? "启用" : "停用"}本地 Skill ${row.name}。请运行 /reload 重新加载。`, "info");
846
+ }
847
+ else {
848
+ await setRecommendedSkillEnabled(ctx.cwd, row.id, action.enabled);
849
+ ctx.ui.notify(`已${action.enabled ? "启用" : "停用"} ${row.name}。请运行 /reload 重新加载。`, "info");
850
+ }
518
851
  }
519
852
  }
520
853
  }
@@ -618,24 +951,62 @@ export default function psyclawExtension(pi) {
618
951
  const legacyTestApi = typeof pi.registerTool !== "function";
619
952
  const runtimeMcps = new RuntimeMcpRegistry();
620
953
  if (!legacyTestApi && typeof pi.on === "function")
621
- pi.on("resources_discover", async (event, ctx) => {
954
+ pi.on("resources_discover", async (event) => {
622
955
  const enabled = await enabledRecommendedSkillPaths(event.cwd);
623
- for (const warning of enabled.warnings)
624
- ctx.ui.notify(`PsyClaw Skill: ${warning}`, "warning");
625
- const localSkillPaths = [
626
- join(homedir(), ".claude", "skills"),
627
- join(homedir(), ".claude", "commands"),
628
- join(homedir(), ".codex", "skills"),
629
- join(homedir(), ".agents", "skills"),
630
- join(event.cwd, ".claude", "skills"),
631
- join(event.cwd, ".claude", "commands"),
632
- join(event.cwd, ".codex", "skills"),
633
- join(event.cwd, ".agents", "skills"),
634
- ];
635
- return { skillPaths: [...enabled.paths, ...localSkillPaths] };
956
+ const local = await enabledLocalSkillPaths(event.cwd, {
957
+ excludedNames: [...coreSkillNames(), ...await skillNamesInPaths(enabled.paths)],
958
+ });
959
+ // Return only existing Skill directories. Same-name local/core candidates
960
+ // are resolved before Pi sees them. Discovery warnings belong in /skill;
961
+ // they should not interrupt every startup.
962
+ return {
963
+ skillPaths: [...enabled.paths, ...local.paths],
964
+ promptPaths: await enabledLocalPromptPaths(event.cwd),
965
+ };
636
966
  });
637
967
  if (!legacyTestApi && typeof pi.on === "function")
638
968
  pi.on("session_shutdown", () => runtimeMcps.close());
969
+ if (!legacyTestApi && typeof pi.on === "function")
970
+ pi.on("tool_call", async (event, ctx) => {
971
+ const run = await readControlledRun(ctx.cwd);
972
+ if (!run || !toolNeedsApproval(event.toolName, event.input))
973
+ return;
974
+ const summary = toolApprovalSummary(event.toolName, event.input);
975
+ if (run.mode === "auto") {
976
+ const blockedReason = autoApprovalBlocked(event.toolName, event.input);
977
+ await appendApproval(ctx.cwd, {
978
+ kind: "tool",
979
+ nodeId: event.toolCallId,
980
+ decision: blockedReason ? "rejected" : "auto-approved",
981
+ actor: "auto",
982
+ runId: run.runId,
983
+ sha256: approvalInputDigest({ toolName: event.toolName, input: event.input }),
984
+ summary: blockedReason ?? summary,
985
+ });
986
+ return blockedReason ? { block: true, terminate: true, reason: blockedReason } : undefined;
987
+ }
988
+ if (!ctx.hasUI)
989
+ return { block: true, terminate: true, reason: "该步骤需要人在环路审批,但当前没有交互界面" };
990
+ const choice = await ctx.ui.select(`审批执行步骤\n${summary}`, ["批准本次执行", "拒绝并停止"], { timeout: 120_000 });
991
+ const approved = choice === "批准本次执行";
992
+ await appendApproval(ctx.cwd, {
993
+ kind: "tool",
994
+ nodeId: event.toolCallId,
995
+ decision: approved ? "approved" : "rejected",
996
+ actor: "human",
997
+ runId: run.runId,
998
+ sha256: approvalInputDigest({ toolName: event.toolName, input: event.input }),
999
+ summary,
1000
+ });
1001
+ return approved ? undefined : { block: true, terminate: true, reason: "用户拒绝该执行步骤或审批超时" };
1002
+ });
1003
+ if (!legacyTestApi && typeof pi.on === "function")
1004
+ pi.on("agent_end", async (_event, ctx) => {
1005
+ if (!pendingInitApprovals.has(ctx.cwd) || !(await planDocumentsReady(ctx.cwd)))
1006
+ return;
1007
+ pendingInitApprovals.delete(ctx.cwd);
1008
+ await reviewPrimaryDocuments(ctx);
1009
+ });
639
1010
  pi.registerCommand("init", {
640
1011
  description: "初始化可追溯的研究项目",
641
1012
  handler: async (args, ctx) => {
@@ -643,8 +1014,9 @@ export default function psyclawExtension(pi) {
643
1014
  const parsed = parseInitArgs(args);
644
1015
  const project = await bootstrapProject({ root: ctx.cwd, ...parsed });
645
1016
  if (typeof pi.sendUserMessage === "function") {
1017
+ pendingInitApprovals.add(ctx.cwd);
646
1018
  pi.sendUserMessage(academicGrillRequest(project.goal, "init"), ctx.isIdle() ? {} : { deliverAs: "followUp" });
647
- ctx.ui.notify(`研究项目已初始化:${project.id}(${project.paradigm})。现在开始学术追问;准备好后使用 /run 启动受控研究流程。`, "info");
1019
+ ctx.ui.notify(`研究项目已初始化:${project.id}(${project.paradigm})。学术追问完成并生成主要计划文档后会进入审批;全部批准后才能 /run。`, "info");
648
1020
  }
649
1021
  else {
650
1022
  ctx.ui.notify(`研究项目已初始化:${project.id}(${project.paradigm})。使用 /run 启动受控研究流程。`, "info");
@@ -655,20 +1027,83 @@ export default function psyclawExtension(pi) {
655
1027
  }
656
1028
  },
657
1029
  });
1030
+ if (!legacyTestApi)
1031
+ pi.registerCommand("approve", {
1032
+ description: "审批 /init 生成的主要研究计划文档",
1033
+ handler: async (_args, ctx) => {
1034
+ try {
1035
+ await reviewPrimaryDocuments(ctx);
1036
+ }
1037
+ catch (error) {
1038
+ await notifyError(ctx, error);
1039
+ }
1040
+ },
1041
+ });
658
1042
  if (!legacyTestApi)
659
1043
  pi.registerCommand("run", {
660
- description: "在 /init 后启动受控研究流程",
1044
+ description: "启动受控研究流程,可用 --skills a,b 选择本次优化 Skill",
661
1045
  handler: async (args, ctx) => {
662
1046
  try {
663
- const project = await readProject(ctx.cwd);
664
- const objective = args.trim() || project.goal;
665
- await activateControlledRun(ctx.cwd, project.id, objective);
666
- pi.appendEntry("psyclaw:controlled-run", { projectId: project.id, objective, activatedAt: new Date().toISOString() });
667
- pi.sendUserMessage(controlledRunRequest(objective), ctx.isIdle() ? {} : { deliverAs: "followUp" });
668
- ctx.ui.notify("受控研究流程已启动。分析完成后会先询问是否撰写论文;不会直接进入 DOCX 导出。", "info");
1047
+ const parsed = parseRunArgs(args);
1048
+ let project;
1049
+ try {
1050
+ project = await readProject(ctx.cwd);
1051
+ }
1052
+ catch (error) {
1053
+ // No `.psyclaw/project.json` (or it is corrupt): fall back to the
1054
+ // compliant analysis documents when present, so a project prepared
1055
+ // outside the /init flow can be analyzed directly.
1056
+ const { bootstrapProjectFromAnalysisDocs } = await import("../../project/bootstrap.js");
1057
+ try {
1058
+ project = await bootstrapProjectFromAnalysisDocs(ctx.cwd);
1059
+ }
1060
+ catch (bootstrapError) {
1061
+ ctx.ui.notify(bootstrapError instanceof Error ? bootstrapError.message : String(bootstrapError), "warning");
1062
+ return;
1063
+ }
1064
+ ctx.ui.notify("检测到合规的分析文档,已自动补齐研究项目记录并启动受控流程。", "info");
1065
+ }
1066
+ const planApproval = await primaryPlanApprovalStatus(ctx.cwd);
1067
+ if (!planApproval.ok) {
1068
+ const missing = planApproval.documents.filter((item) => !item.approved).map((item) => item.path);
1069
+ ctx.ui.notify(`主要计划尚未批准或批准后已修改:${missing.join("、")}。请运行 /approve。`, "warning");
1070
+ return;
1071
+ }
1072
+ const available = await selectableRunSkills(ctx.cwd);
1073
+ let requestedSkills = parsed.requestedSkills;
1074
+ if (parsed.mode === "auto" && requestedSkills === undefined)
1075
+ requestedSkills = [];
1076
+ if (requestedSkills === undefined && ctx.hasUI && typeof ctx.ui.input === "function") {
1077
+ const answer = await ctx.ui.input("选择本次 Run 使用的 Skill(逗号分隔,可留空)", available.map((skill) => skill.name).join(", "));
1078
+ if (answer === undefined) {
1079
+ ctx.ui.notify("已取消启动受控研究流程", "info");
1080
+ return;
1081
+ }
1082
+ requestedSkills = answer.split(",").map((item) => item.trim()).filter(Boolean);
1083
+ }
1084
+ const selectedSkills = resolveRunSkills(requestedSkills ?? [], available);
1085
+ const objective = parsed.objective || project.goal;
1086
+ const runId = `run_${Date.now()}`;
1087
+ await appendApproval(ctx.cwd, {
1088
+ kind: "run-mode",
1089
+ nodeId: "run-mode",
1090
+ decision: parsed.mode === "auto" ? "auto-approved" : "approved",
1091
+ actor: parsed.mode === "auto" ? "auto" : "human",
1092
+ runId,
1093
+ sha256: approvalInputDigest({ projectId: project.id, objective, selectedSkills, mode: parsed.mode }),
1094
+ summary: parsed.mode === "auto" ? "用户选择自动运行模式" : "用户选择人在环路模式",
1095
+ });
1096
+ if (!(await approveRunNodes(ctx, runId, parsed.mode))) {
1097
+ ctx.ui.notify("运行审批被拒绝或超时,未启动。", "warning");
1098
+ return;
1099
+ }
1100
+ await activateControlledRun(ctx.cwd, runId, project.id, objective, selectedSkills, parsed.mode);
1101
+ pi.appendEntry("psyclaw:controlled-run", { runId, projectId: project.id, objective, selectedSkills, mode: parsed.mode, activatedAt: new Date().toISOString() });
1102
+ pi.sendUserMessage(controlledRunRequest(objective, selectedSkills, parsed.mode), ctx.isIdle() ? {} : { deliverAs: "followUp" });
1103
+ ctx.ui.notify(`${parsed.mode === "auto" ? "自动" : "人在环路"}研究流程已启动${selectedSkills.length > 0 ? `;本次使用 Skill:${selectedSkills.join(", ")}` : ";使用默认 Skill"}。`, "info");
669
1104
  }
670
- catch {
671
- ctx.ui.notify("请先使用 /init 初始化研究项目;未运行 /init 和 /run 时保持普通对话模式。", "warning");
1105
+ catch (error) {
1106
+ await notifyError(ctx, error);
672
1107
  }
673
1108
  },
674
1109
  });
@@ -803,27 +1238,73 @@ export default function psyclawExtension(pi) {
803
1238
  },
804
1239
  });
805
1240
  if (!legacyTestApi)
806
- pi.registerCommand("skills", {
807
- description: "打开 Skill 安装与启用管理页",
1241
+ pi.registerCommand("skill", {
1242
+ description: "管理和安装 Skill",
808
1243
  handler: async (args, ctx) => {
809
- try {
810
- await showSkillManager(pi, args, ctx);
1244
+ const [name, ...rest] = args.trim().split(/\s+/).filter(Boolean);
1245
+ if (!name) {
1246
+ try {
1247
+ await showSkillManager(pi, "", ctx);
1248
+ }
1249
+ catch (error) {
1250
+ await notifyError(ctx, error);
1251
+ }
1252
+ return;
811
1253
  }
812
- catch (error) {
813
- await notifyError(ctx, error);
1254
+ if (name === "install") {
1255
+ const source = rest.join(" ").trim();
1256
+ if (!source) {
1257
+ ctx.ui.notify("Usage: /skill install <local-directory>", "info");
1258
+ return;
1259
+ }
1260
+ try {
1261
+ const installed = await installLocalSkill(source, ctx.cwd);
1262
+ ctx.ui.notify(`已安装本地 Skill ${installed.name}:${installed.target}。请执行 /reload。`, "info");
1263
+ }
1264
+ catch (error) {
1265
+ await notifyError(ctx, error);
1266
+ }
1267
+ return;
814
1268
  }
1269
+ if (["status", "enable", "disable", "enable-all", "disable-all"].includes(name)) {
1270
+ try {
1271
+ await showSkillManager(pi, args, ctx);
1272
+ }
1273
+ catch (error) {
1274
+ await notifyError(ctx, error);
1275
+ }
1276
+ return;
1277
+ }
1278
+ ctx.ui.notify("Usage: /skill [status|enable <id>|disable <id>|enable-all|disable-all|install <local-directory>]", "info");
815
1279
  },
816
1280
  });
817
1281
  if (!legacyTestApi)
818
- pi.registerCommand("skill", {
819
- description: "调用任意已加载 Skill(/skill <name> [任务])",
1282
+ pi.registerCommand("plugin", {
1283
+ description: "管理 Plugin / Extension",
820
1284
  handler: async (args, ctx) => {
821
- const [name, ...rest] = args.trim().split(/\s+/).filter(Boolean);
822
- if (!name || !/^[a-z0-9-]+$/.test(name)) {
823
- ctx.ui.notify("Usage: /skill <name> [task];也可直接使用 /skill:<name>", "info");
824
- return;
1285
+ try {
1286
+ const [action, ...rest] = args.trim().split(/\s+/).filter(Boolean);
1287
+ if (!action) {
1288
+ ctx.ui.notify("Usage: /plugin list | install <source> [-l] | remove <source> [-l]", "info");
1289
+ return;
1290
+ }
1291
+ if (!["list", "install", "remove"].includes(action)) {
1292
+ throw new Error("Usage: /plugin list | install <source> [-l] | remove <source> [-l]");
1293
+ }
1294
+ if (action !== "list" && rest.length === 0)
1295
+ throw new Error(`Usage: /plugin ${action} <source> [-l]`);
1296
+ if (action !== "list") {
1297
+ const approved = await ctx.ui.confirm(`${action === "install" ? "安装" : "移除"} Plugin?`, `${rest.join(" ")}\nPlugin 与宿主进程同权限,操作完成后需要重启 PsyClaw。`);
1298
+ if (!approved)
1299
+ return;
1300
+ }
1301
+ await runPluginCommand([action, ...rest]);
1302
+ if (action !== "list")
1303
+ ctx.ui.notify("Plugin 配置已更新,请重启 PsyClaw。", "info");
1304
+ }
1305
+ catch (error) {
1306
+ await notifyError(ctx, error);
825
1307
  }
826
- pi.sendUserMessage(`/skill:${name}${rest.length ? ` ${rest.join(" ")}` : ""}`, ctx.isIdle() ? {} : { deliverAs: "followUp" });
827
1308
  },
828
1309
  });
829
1310
  if (!legacyTestApi)
@@ -831,7 +1312,7 @@ export default function psyclawExtension(pi) {
831
1312
  description: "打开 MCP 安装与配置管理页",
832
1313
  handler: async (args, ctx) => {
833
1314
  try {
834
- await showMcpManager(pi, args, ctx);
1315
+ await showMcpManager(pi, args, ctx, runtimeMcps);
835
1316
  }
836
1317
  catch (error) {
837
1318
  await notifyError(ctx, error);
@@ -849,7 +1330,7 @@ export default function psyclawExtension(pi) {
849
1330
  const skillLines = skills.items.slice(0, 8).map((item) => `Skill: ${String(item.id)} — ${String(item.name)}`);
850
1331
  const mcpLines = mcps.items.slice(0, 8).map((item) => `MCP: ${String(item.id)} — ${String(item.name)}`);
851
1332
  const toolLines = skills.externalTools.slice(0, 8).map((item) => `外部工具: ${String(item.name)} — ${String(item.sourceRef ?? "请查看项目文档")}`);
852
- ctx.ui.notify(["推荐安装入口", "", ...skillLines, ...mcpLines, "", ...toolLines, "", "安装:/install skill|mcp <id>", "管理:/skills 或 /mcp", "外部工具不通过 Skill 安装器安装", "也可以打开 /panel 查看推荐页面"].join("\n"), "info");
1333
+ ctx.ui.notify(["推荐安装入口", "", ...skillLines, ...mcpLines, "", ...toolLines, "", "安装:/install skill|mcp <id>", "管理:/skill 或 /mcp", "外部工具不通过 Skill 安装器安装", "也可以打开 /panel 查看推荐页面"].join("\n"), "info");
853
1334
  return;
854
1335
  }
855
1336
  if (kind !== "skill" && kind !== "mcp")
@@ -858,13 +1339,13 @@ export default function psyclawExtension(pi) {
858
1339
  if (kind === "skill")
859
1340
  await showSkillManager(pi, "", ctx);
860
1341
  else
861
- await showMcpManager(pi, "", ctx);
1342
+ await showMcpManager(pi, "", ctx, runtimeMcps);
862
1343
  return;
863
1344
  }
864
1345
  if (kind === "skill")
865
1346
  await showSkillManager(pi, `install ${id}`, ctx);
866
1347
  else
867
- await showMcpManager(pi, `install ${id}`, ctx);
1348
+ await showMcpManager(pi, `install ${id}`, ctx, runtimeMcps);
868
1349
  }
869
1350
  catch (error) {
870
1351
  await notifyError(ctx, error);
@@ -933,11 +1414,19 @@ export default function psyclawExtension(pi) {
933
1414
  selectedId = choice;
934
1415
  }
935
1416
  if (preset && ctx.hasUI && typeof ctx.ui.custom === "function") {
936
- const key = await promptProviderKey(ctx, preset.name, preset.apiKeyEnv);
1417
+ const credential = await providerCredentialSource(preset);
1418
+ const key = credential === "missing" ? await promptProviderKey(ctx, preset.name, preset.apiKeyEnv) : "";
937
1419
  if (key === undefined)
938
1420
  return;
939
1421
  await saveProviderConfig({ ...preset, ...(key ? { apiKey: key } : {}) });
940
- await ctx.modelRegistry.refresh();
1422
+ const refreshed = await Promise.race([
1423
+ ctx.modelRegistry.refresh().then(() => true),
1424
+ new Promise((resolve) => setTimeout(() => resolve(false), 5_000)),
1425
+ ]);
1426
+ if (!refreshed) {
1427
+ ctx.ui.notify("Provider 配置已保存;模型目录刷新超时,请重新启动 PsyClaw 后使用。", "warning");
1428
+ return;
1429
+ }
941
1430
  models = ctx.modelRegistry.getAll().filter((model) => model.provider === requested);
942
1431
  }
943
1432
  const selected = models.find((model) => model.id === selectedId);
@@ -972,18 +1461,18 @@ export default function psyclawExtension(pi) {
972
1461
  },
973
1462
  });
974
1463
  const traceCommand = {
975
- description: "导出脱敏使用路径,供 Langfuse 或 LangSmith 分析",
1464
+ description: "导出包含正文与工具参数的完整使用路径,供 Langfuse 或 LangSmith 分析",
976
1465
  handler: async (args, ctx) => {
977
1466
  try {
978
1467
  if (args.trim())
979
- throw new Error("Usage: /trace");
1468
+ throw new Error("Usage: /export");
980
1469
  const result = await exportTraces({ root: ctx.cwd });
981
1470
  ctx.ui.notify([
982
1471
  "使用路径已导出(未上传)",
983
1472
  `文件:${result.output}`,
984
1473
  `轨迹:${result.traces}`,
985
1474
  `步骤:${result.spans}`,
986
- "隐私:不含对话正文、工具参数、原始 ID 或绝对路径",
1475
+ "包含对话正文、工具参数、原始 ID 与绝对路径,便于排查各环节问题;请勿将导出文件提交到公开仓库。",
987
1476
  ].join("\n"), "info");
988
1477
  }
989
1478
  catch (error) {
@@ -991,7 +1480,7 @@ export default function psyclawExtension(pi) {
991
1480
  }
992
1481
  },
993
1482
  };
994
- pi.registerCommand("trace", traceCommand);
1483
+ pi.registerCommand("export", traceCommand);
995
1484
  if (typeof pi.registerTool === "function") {
996
1485
  pi.registerTool({
997
1486
  name: "psyclaw_mcp",
@@ -1260,7 +1749,10 @@ export default function psyclawExtension(pi) {
1260
1749
  }
1261
1750
  },
1262
1751
  });
1263
- if (developerCommands || legacyTestApi)
1752
+ // Read-only multi-agent research is a user-facing capability. Keep the
1753
+ // destructive/developer commands gated, but do not hide the bounded agent
1754
+ // runner behind PSYCLAW_DEVELOPER_COMMANDS in published builds.
1755
+ if (!legacyTestApi)
1264
1756
  pi.registerCommand("agents", {
1265
1757
  description: "运行经批准的只读研究 Agent",
1266
1758
  handler: async (args, ctx) => {
@@ -1287,7 +1779,7 @@ export default function psyclawExtension(pi) {
1287
1779
  ctx.ui.notify("请先使用 /init 初始化研究项目,再运行 /agents", "warning");
1288
1780
  return;
1289
1781
  }
1290
- const approved = await ctx.ui.confirm("Run read-only research worker?", "The worker runs in a separate Pi process with extensions, skills, context files, and mutating tools disabled.");
1782
+ const approved = await ctx.ui.confirm("Run read-only research worker?", "The worker runs in a separate PsyClaw process with extensions, skills, context files, and mutating tools disabled.");
1291
1783
  if (!approved) {
1292
1784
  ctx.ui.notify("Agent run canceled", "info");
1293
1785
  return;