psyclaw 0.27.7 → 0.27.13

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 (57) hide show
  1. package/README.md +21 -12
  2. package/dist/src/adapters/pi/extension.js +284 -71
  3. package/dist/src/adapters/pi/extension.js.map +1 -1
  4. package/dist/src/agents/import.js +2 -1
  5. package/dist/src/agents/import.js.map +1 -1
  6. package/dist/src/analysis/hooks.d.ts +19 -2
  7. package/dist/src/analysis/hooks.js +48 -7
  8. package/dist/src/analysis/hooks.js.map +1 -1
  9. package/dist/src/branding.d.ts +2 -2
  10. package/dist/src/branding.js +2 -2
  11. package/dist/src/branding.js.map +1 -1
  12. package/dist/src/chat.js +5 -2
  13. package/dist/src/chat.js.map +1 -1
  14. package/dist/src/cli.js +20 -13
  15. package/dist/src/cli.js.map +1 -1
  16. package/dist/src/index.d.ts +1 -1
  17. package/dist/src/index.js +1 -1
  18. package/dist/src/index.js.map +1 -1
  19. package/dist/src/install/installer.js +3 -0
  20. package/dist/src/install/installer.js.map +1 -1
  21. package/dist/src/orchestration/runner.js +1 -0
  22. package/dist/src/orchestration/runner.js.map +1 -1
  23. package/dist/src/panel/server.js +16 -10
  24. package/dist/src/panel/server.js.map +1 -1
  25. package/dist/src/project/approvals.d.ts +50 -0
  26. package/dist/src/project/approvals.js +62 -0
  27. package/dist/src/project/approvals.js.map +1 -0
  28. package/dist/src/project/paths.js +5 -2
  29. package/dist/src/project/paths.js.map +1 -1
  30. package/dist/src/research/ledger.d.ts +3 -2
  31. package/dist/src/research/ledger.js +47 -2
  32. package/dist/src/research/ledger.js.map +1 -1
  33. package/dist/src/setup.js +32 -10
  34. package/dist/src/setup.js.map +1 -1
  35. package/dist/src/skills/user-skills.d.ts +9 -1
  36. package/dist/src/skills/user-skills.js +48 -2
  37. package/dist/src/skills/user-skills.js.map +1 -1
  38. package/dist/src/style/cli-ui.js +19 -30
  39. package/dist/src/style/cli-ui.js.map +1 -1
  40. package/dist/src/tui/provider-picker.d.ts +2 -0
  41. package/dist/src/tui/provider-picker.js +13 -3
  42. package/dist/src/tui/provider-picker.js.map +1 -1
  43. package/dist/src/updates/update.js +13 -20
  44. package/dist/src/updates/update.js.map +1 -1
  45. package/dist/src/wizard.js +30 -12
  46. package/dist/src/wizard.js.map +1 -1
  47. package/dist/src/workflows/analysis-delegation.js +2 -2
  48. package/dist/src/workflows/analysis-delegation.js.map +1 -1
  49. package/package.json +2 -2
  50. package/scripts/rebrand-pi.mjs +29 -1
  51. package/skills/core/citation-audit/SKILL.md +9 -9
  52. package/skills/core/evidence-capture/SKILL.md +9 -9
  53. package/skills/core/research-brief/SKILL.md +8 -8
  54. package/skills/core/research-intake/SKILL.md +9 -9
  55. package/dist/src/telemetry/export.d.ts +0 -58
  56. package/dist/src/telemetry/export.js +0 -428
  57. package/dist/src/telemetry/export.js.map +0 -1
@@ -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, 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
9
  import { lstat, mkdir, readFile } from "node:fs/promises";
10
- import { PROVIDER_PRESETS, saveProviderConfig } from "../../setup.js";
10
+ import { PROVIDER_PRESETS, providerCredentialSource, saveProviderConfig } from "../../setup.js";
11
11
  import { dirname } from "node:path";
12
- import { fileURLToPath } from "node:url";
12
+ import { fileURLToPath, pathToFileURL } from "node:url";
13
13
  import { coreSkillNames, enabledRecommendedSkillPaths, normalizeRecommendedSkillId, readRecommendationState, readRecommendedCatalog, recommendedSkillTarget, saveRecommendationState, validateModelInstalledRecommendedSkill, } from "../../skills/recommended.js";
14
14
  import { SkillManagerComponent } from "../../tui/skill-manager.js";
15
- import { enabledLocalSkillPaths, enabledLocalPromptPaths, readUserSkillState, scanLocalSkills, setLocalSkillEnabled, setLocalSkillsEnabled, skillNamesInPaths, userSkillId, } from "../../skills/user-skills.js";
15
+ import { enabledLocalSkillPaths, enabledLocalPromptPaths, readUserSkillState, scanLocalSkills, setLocalSkillEnabled, setLocalSkillsEnabled, skillNamesInPaths, installLocalSkill, userSkillId, } from "../../skills/user-skills.js";
16
16
  import { RuntimeMcpRegistry, setUserMcpConfigEnabled, } from "../../integrations/mcp-runtime.js";
17
- import { ProviderPickerComponent, SecretInputComponent, } from "../../tui/provider-picker.js";
17
+ import { SecretInputComponent, ProviderPickerComponent, } 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,14 @@ 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
+ if (typeof ctx.ui.custom !== "function") {
124
+ const labels = items.map((item) => `${item.current ? "* " : ""}${item.label} (${item.id})`);
125
+ const selected = await ctx.ui.select(title, labels, { timeout: 60_000 });
126
+ const index = selected === undefined ? -1 : labels.indexOf(selected);
127
+ return index < 0 ? undefined : items[index].id;
128
+ }
129
+ const selected = await ctx.ui.custom((tui, theme, keybindings, done) => new ProviderPickerComponent(title, items, tui, theme, keybindings, done));
130
+ return selected.type === "select" ? selected.id : undefined;
115
131
  }
116
132
  async function promptProviderKey(ctx, providerName, envName) {
117
133
  const result = await ctx.ui.custom((tui, theme, keybindings, done) => new SecretInputComponent(`配置 ${providerName}`, envName, tui, theme, keybindings, done));
@@ -154,7 +170,7 @@ function academicGrillRequest(subject, mode) {
154
170
  "从最上游、影响最大的未决问题开始。每一轮只问一个问题,同时给出你的推荐答案或推荐决策及主要取舍。",
155
171
  "明确区分探索性与确证性研究,不把规划内容当作已有证据。直到关键分支已经解决或明确交由研究者决定后,再整理研究规格。",
156
172
  mode === "init"
157
- ? "持久化模式:init。追问完成后直接更新 academic-grill Skill 规定的全部项目文档,不再询问是否更新;写入后列出实际更新的文件。"
173
+ ? "持久化模式:init。追问完成后直接更新 academic-grill Skill 规定的全部项目文档,不再询问是否更新;写入后列出实际更新的文件,并把 notes/research-spec.md 与 notes/plan.md 的 Status 统一写为 awaiting-human-approval。"
158
174
  : "持久化模式:review。追问完成后先展示拟更新摘要和受影响文件,并询问我是否写回;得到明确确认前不得修改项目文档。",
159
175
  ].join("\n");
160
176
  }
@@ -165,32 +181,37 @@ async function readControlledRun(root) {
165
181
  try {
166
182
  const value = JSON.parse(await readFile(await controlledRunPath(root), "utf8"));
167
183
  return value.schemaVersion === "psyclaw/controlled-run/v1" && value.status === "active" && typeof value.projectId === "string" && typeof value.objective === "string" && typeof value.activatedAt === "string"
168
- ? { ...value, selectedSkills: Array.isArray(value.selectedSkills) ? value.selectedSkills.filter((item) => typeof item === "string") : [] }
184
+ ? { ...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
185
  : null;
170
186
  }
171
187
  catch {
172
188
  return null;
173
189
  }
174
190
  }
175
- async function activateControlledRun(root, projectId, objective, selectedSkills) {
191
+ async function activateControlledRun(root, runId, projectId, objective, selectedSkills, mode) {
176
192
  const state = {
177
193
  schemaVersion: "psyclaw/controlled-run/v1",
194
+ runId,
178
195
  projectId,
179
196
  objective,
180
197
  selectedSkills: [...new Set(selectedSkills)],
198
+ mode,
181
199
  activatedAt: new Date().toISOString(),
182
200
  status: "active",
183
201
  };
184
202
  await atomicWriteFile(await controlledRunPath(root), `${JSON.stringify(state, null, 2)}\n`);
185
203
  return state;
186
204
  }
187
- function controlledRunRequest(objective, selectedSkills) {
205
+ function controlledRunRequest(objective, selectedSkills, mode) {
188
206
  return [
189
- "PsyClaw 受控研究流程已由用户通过 /run 明确启动。先读取 .psyclaw/project.json、.psyclaw/controlled-run.json、notes/research-spec.md 和 notes/plan.md。",
207
+ `PsyClaw ${mode === "auto" ? "自动" : "人在环路"}研究流程已由用户通过 /run 明确启动。先读取 .psyclaw/project.json、.psyclaw/controlled-run.json、.psyclaw/approvals.jsonl、notes/research-spec.md 和 notes/plan.md。`,
190
208
  `本次目标:${objective}`,
191
209
  selectedSkills.length > 0
192
210
  ? `本次运行由用户选择的优化 Skill:${selectedSkills.join(", ")}。在相关任务中优先加载并遵循这些 Skill;同名 Skill 只加载一次。`
193
211
  : "本次运行未指定额外 Skill,使用 PsyClaw 默认研究流程。不要删除、停用或改写已安装的其他 Skill。",
212
+ mode === "auto"
213
+ ? "自动模式:计划内普通步骤连续推进,不再逐项询问;运行时会自动记录审批。原始数据、凭据、破坏性命令、伦理决定、未经核验的事实与外部发布仍必须停止并请求用户决定。"
214
+ : "人在环路模式:每个有副作用或关键方法决策的步骤都必须通过交互审批;拒绝或超时立即停止,不得绕过审批。",
194
215
  "实际推进当前尚未完成的研究任务;先形成或更新符合学术规范的 Markdown 分析报告,不能只描述计划。分析报告必须清楚区分数据来源、统计结果、文献证据、限制与尚未核验内容。",
195
216
  "分析报告完成后,下一步不是直接询问是否导出 DOCX,而是询问用户是否据此撰写论文。",
196
217
  "若用户选择撰写论文,再单独询问是否先进行文献调研,并说明该阶段可能花费较长时间;提示用户可以指定已启用的 Skill,或者选择 PsyClaw 默认的文献调研方式。不要在获得答复前自动开始长时调研。",
@@ -202,16 +223,92 @@ function controlledRunRequest(objective, selectedSkills) {
202
223
  }
203
224
  function parseRunArgs(args) {
204
225
  const value = args.trim();
205
- if (!value.startsWith("--skills"))
206
- return { objective: value };
207
- const match = value.match(/^--skills(?:=|\s+)([^\s]+)(?:\s+([\s\S]*))?$/);
226
+ const auto = value === "auto" || value.startsWith("auto ");
227
+ const rest = auto ? value.slice(4).trim() : value;
228
+ if (!rest.startsWith("--skills"))
229
+ return { objective: rest, mode: auto ? "auto" : "human" };
230
+ const match = rest.match(/^--skills(?:=|\s+)([^\s]+)(?:\s+([\s\S]*))?$/);
208
231
  if (!match)
209
- throw new Error("Usage: /run [--skills skill-a,skill-b] [objective]");
232
+ throw new Error("Usage: /run [auto] [--skills skill-a,skill-b] [objective]");
210
233
  return {
211
234
  objective: (match[2] ?? "").trim(),
212
235
  requestedSkills: [...new Set(match[1].split(",").map((item) => item.trim()).filter(Boolean))],
236
+ mode: auto ? "auto" : "human",
213
237
  };
214
238
  }
239
+ async function planDocumentsReady(root) {
240
+ try {
241
+ const [spec, plan] = await Promise.all([
242
+ readFile(join(root, "notes", "research-spec.md"), "utf8"),
243
+ readFile(join(root, "notes", "plan.md"), "utf8"),
244
+ ]);
245
+ return /Status:\s*awaiting-human-approval/i.test(spec) && /Status:\s*awaiting-human-approval/i.test(plan);
246
+ }
247
+ catch {
248
+ return false;
249
+ }
250
+ }
251
+ async function reviewPrimaryDocuments(ctx) {
252
+ if (!ctx.hasUI || activeApprovalDialogs.has(ctx.cwd))
253
+ return false;
254
+ if (!(await planDocumentsReady(ctx.cwd))) {
255
+ ctx.ui.notify("主要计划文档尚未生成完成;请先完成 /init 的学术追问。", "warning");
256
+ return false;
257
+ }
258
+ activeApprovalDialogs.add(ctx.cwd);
259
+ try {
260
+ for (const document of PRIMARY_PLAN_DOCUMENTS) {
261
+ const choice = await ctx.ui.select(`审批${document.title}:${document.path}`, ["批准当前版本", "拒绝并返回修改"], { timeout: 120_000 });
262
+ const approved = choice === "批准当前版本";
263
+ await approvePrimaryDocument(ctx.cwd, document, approved ? "approved" : "rejected");
264
+ if (!approved) {
265
+ ctx.ui.notify(`${document.title}未批准;修改后运行 /approve 重新审批。`, "warning");
266
+ return false;
267
+ }
268
+ }
269
+ ctx.ui.notify("主要计划文档已批准。文档内容变化后审批会自动失效。", "info");
270
+ return true;
271
+ }
272
+ finally {
273
+ activeApprovalDialogs.delete(ctx.cwd);
274
+ }
275
+ }
276
+ async function approveRunNodes(ctx, runId, mode) {
277
+ for (const node of DEFAULT_RUN_APPROVAL_NODES) {
278
+ if (mode === "auto") {
279
+ await appendApproval(ctx.cwd, { kind: "step", nodeId: node.id, decision: "auto-approved", actor: "auto", runId, summary: node.summary });
280
+ continue;
281
+ }
282
+ const options = node.required ? ["批准", "拒绝并停止"] : ["批准", "本次跳过", "拒绝并停止"];
283
+ const choice = await ctx.ui.select(`${node.title}\n${node.summary}`, options, { timeout: 120_000 });
284
+ const decision = choice === "批准" ? "approved" : choice === "本次跳过" ? "skipped" : "rejected";
285
+ await appendApproval(ctx.cwd, { kind: "step", nodeId: node.id, decision, actor: "human", runId, summary: node.summary });
286
+ if (decision === "rejected" || (node.required && decision !== "approved"))
287
+ return false;
288
+ }
289
+ return true;
290
+ }
291
+ function toolApprovalSummary(toolName, input) {
292
+ const record = input && typeof input === "object" && !Array.isArray(input) ? input : {};
293
+ const target = [record.path, record.file_path, record.command, record.action, record.server, record.tool]
294
+ .find((value) => typeof value === "string");
295
+ return `${toolName}${target ? `: ${target.slice(0, 240)}` : ""}`;
296
+ }
297
+ function toolNeedsApproval(toolName, input) {
298
+ if (["write", "edit", "bash", "powershell", "psyclaw_workbench", "psyclaw_mcp"].includes(toolName))
299
+ return true;
300
+ const action = input && typeof input === "object" && !Array.isArray(input) ? input.action : undefined;
301
+ return typeof action === "string" && /write|save|publish|install|download|execute|call/i.test(action);
302
+ }
303
+ function autoApprovalBlocked(toolName, input) {
304
+ const text = JSON.stringify(input ?? {}).toLocaleLowerCase();
305
+ if (/data[\\/]raw|credential|secret|auth\.json|api[_ -]?key/.test(text))
306
+ return "自动模式不能批准原始数据或凭据路径操作";
307
+ if (["bash", "powershell"].includes(toolName) && /\b(rm|rmdir|del|remove-item|format|shutdown|reboot|git\s+reset\s+--hard)\b/i.test(text)) {
308
+ return "自动模式不能批准破坏性命令";
309
+ }
310
+ return undefined;
311
+ }
215
312
  async function selectableRunSkills(root) {
216
313
  const state = await readRecommendationState(root);
217
314
  const rows = await skillManagerRows(root, state);
@@ -244,7 +341,7 @@ function resolveRunSkills(requested, available) {
244
341
  selected.push(name);
245
342
  }
246
343
  if (unknown.length > 0)
247
- throw new Error(`本次运行不可用的 Skill:${unknown.join(", ")}。请先通过 /skills 安装或启用。`);
344
+ throw new Error(`本次运行不可用的 Skill:${unknown.join(", ")}。请先通过 /skill 安装或启用。`);
248
345
  return selected;
249
346
  }
250
347
  async function recommendedItems(kind) {
@@ -597,12 +694,12 @@ function modelSkillInstallTask(root, row, scope) {
597
694
  ...(row.installHint ? [`仓库入口提示:${row.installHint}`] : []),
598
695
  `安装位置:${skillScopeLabel(scope)}。`,
599
696
  `唯一允许的最终目标目录:${target}`,
600
- "用户已通过 Skill 管理页授权本次安装。请使用当前会话的联网、文件和命令工具读取来源仓库,并直接下载、安装所需依赖和完成 Skill 安装;不要再次要求安装权限。不要写入其他 Skill 目录,不要修改 data/raw、.git 或研究产物。",
697
+ "用户已通过 Skill 管理页授权本次安装。请使用当前会话的联网、文件和命令工具读取来源仓库,并直接下载、安装所需依赖和完成 Skill 安装;不要再次要求安装权限。不要写入其他 Skill 目录,不要修改 .psyclaw/data/raw、data/raw、.git 或研究产物。",
601
698
  row.collection
602
699
  ? "这是多 Skill 套件:目标目录自身无需 SKILL.md,但其子目录必须包含一个或多个有效 SKILL.md。保留套件内共享目录和相对路径,不得包含 .git、符号链接或凭据。"
603
700
  : "目标目录最终必须直接包含有效 SKILL.md(YAML frontmatter 至少包含 name 和 description),不得包含 .git、符号链接、凭据或二进制大文件。",
604
701
  "如果仓库包含多个 Skill,只安装与此推荐项相符的部分;如果它不是 Skill 或无法合理适配,停止并说明原因,不要伪造 SKILL.md。",
605
- "安装完成后检查目标目录结构,并提醒用户执行 /skills 启用该项,再执行 /reload。",
702
+ "安装完成后检查目标目录结构,并提醒用户执行 /skill 启用该项,再执行 /reload。",
606
703
  ].join("\n");
607
704
  }
608
705
  async function chooseSkillScope(ctx) {
@@ -628,17 +725,17 @@ async function queueModelSkillInstall(pi, ctx, row) {
628
725
  state.skillScopes = { ...(state.skillScopes ?? {}), [row.id]: scope };
629
726
  await saveRecommendationState(ctx.cwd, state);
630
727
  pi.sendUserMessage(modelSkillInstallTask(ctx.cwd, row, scope), ctx.isIdle() ? {} : { deliverAs: "followUp" });
631
- ctx.ui.notify(`已将 ${row.name} 的安装任务交给当前模型,目标为${skillScopeLabel(scope)}。安装完成后请在 /skills 中启用,再执行 /reload。`, "info");
728
+ ctx.ui.notify(`已将 ${row.name} 的安装任务交给当前模型,目标为${skillScopeLabel(scope)}。安装完成后请在 /skill 中启用,再执行 /reload。`, "info");
632
729
  }
633
730
  async function showSkillManager(pi, args, ctx) {
634
731
  const action = args.trim().split(/\s+/).filter(Boolean);
635
732
  const verb = action[0];
636
733
  const id = action[1];
637
734
  if (verb && !["status", "enable", "disable", "enable-all", "disable-all", "install"].includes(verb)) {
638
- throw new Error("Usage: /skills [status|enable <id>|disable <id>|enable-all|disable-all|install <id>]");
735
+ throw new Error("Usage: /skill [status|enable <id>|disable <id>|enable-all|disable-all]");
639
736
  }
640
737
  if ((verb === "enable" || verb === "disable" || verb === "install") && !id) {
641
- throw new Error(`Usage: /skills ${verb} <id>`);
738
+ throw new Error(`Usage: /skill ${verb} <id>`);
642
739
  }
643
740
  if (verb === "install") {
644
741
  const row = (await skillManagerRows(ctx.cwd, await readRecommendationState(ctx.cwd))).find((candidate) => candidate.id === normalizeRecommendedSkillId(id));
@@ -858,17 +955,14 @@ export default function psyclawExtension(pi) {
858
955
  const legacyTestApi = typeof pi.registerTool !== "function";
859
956
  const runtimeMcps = new RuntimeMcpRegistry();
860
957
  if (!legacyTestApi && typeof pi.on === "function")
861
- pi.on("resources_discover", async (event, ctx) => {
958
+ pi.on("resources_discover", async (event) => {
862
959
  const enabled = await enabledRecommendedSkillPaths(event.cwd);
863
- for (const warning of enabled.warnings)
864
- ctx.ui.notify(`PsyClaw Skill: ${warning}`, "warning");
865
960
  const local = await enabledLocalSkillPaths(event.cwd, {
866
961
  excludedNames: [...coreSkillNames(), ...await skillNamesInPaths(enabled.paths)],
867
962
  });
868
- for (const warning of local.warnings)
869
- ctx.ui.notify(`PsyClaw Skill: ${warning}`, "warning");
870
963
  // Return only existing Skill directories. Same-name local/core candidates
871
- // are resolved before Pi sees them, so harmless collisions stay silent.
964
+ // are resolved before Pi sees them. Discovery warnings belong in /skill;
965
+ // they should not interrupt every startup.
872
966
  return {
873
967
  skillPaths: [...enabled.paths, ...local.paths],
874
968
  promptPaths: await enabledLocalPromptPaths(event.cwd),
@@ -876,6 +970,47 @@ export default function psyclawExtension(pi) {
876
970
  });
877
971
  if (!legacyTestApi && typeof pi.on === "function")
878
972
  pi.on("session_shutdown", () => runtimeMcps.close());
973
+ if (!legacyTestApi && typeof pi.on === "function")
974
+ pi.on("tool_call", async (event, ctx) => {
975
+ const run = await readControlledRun(ctx.cwd);
976
+ if (!run || !toolNeedsApproval(event.toolName, event.input))
977
+ return;
978
+ const summary = toolApprovalSummary(event.toolName, event.input);
979
+ if (run.mode === "auto") {
980
+ const blockedReason = autoApprovalBlocked(event.toolName, event.input);
981
+ await appendApproval(ctx.cwd, {
982
+ kind: "tool",
983
+ nodeId: event.toolCallId,
984
+ decision: blockedReason ? "rejected" : "auto-approved",
985
+ actor: "auto",
986
+ runId: run.runId,
987
+ sha256: approvalInputDigest({ toolName: event.toolName, input: event.input }),
988
+ summary: blockedReason ?? summary,
989
+ });
990
+ return blockedReason ? { block: true, terminate: true, reason: blockedReason } : undefined;
991
+ }
992
+ if (!ctx.hasUI)
993
+ return { block: true, terminate: true, reason: "该步骤需要人在环路审批,但当前没有交互界面" };
994
+ const choice = await ctx.ui.select(`审批执行步骤\n${summary}`, ["批准本次执行", "拒绝并停止"], { timeout: 120_000 });
995
+ const approved = choice === "批准本次执行";
996
+ await appendApproval(ctx.cwd, {
997
+ kind: "tool",
998
+ nodeId: event.toolCallId,
999
+ decision: approved ? "approved" : "rejected",
1000
+ actor: "human",
1001
+ runId: run.runId,
1002
+ sha256: approvalInputDigest({ toolName: event.toolName, input: event.input }),
1003
+ summary,
1004
+ });
1005
+ return approved ? undefined : { block: true, terminate: true, reason: "用户拒绝该执行步骤或审批超时" };
1006
+ });
1007
+ if (!legacyTestApi && typeof pi.on === "function")
1008
+ pi.on("agent_end", async (_event, ctx) => {
1009
+ if (!pendingInitApprovals.has(ctx.cwd) || !(await planDocumentsReady(ctx.cwd)))
1010
+ return;
1011
+ pendingInitApprovals.delete(ctx.cwd);
1012
+ await reviewPrimaryDocuments(ctx);
1013
+ });
879
1014
  pi.registerCommand("init", {
880
1015
  description: "初始化可追溯的研究项目",
881
1016
  handler: async (args, ctx) => {
@@ -883,8 +1018,9 @@ export default function psyclawExtension(pi) {
883
1018
  const parsed = parseInitArgs(args);
884
1019
  const project = await bootstrapProject({ root: ctx.cwd, ...parsed });
885
1020
  if (typeof pi.sendUserMessage === "function") {
1021
+ pendingInitApprovals.add(ctx.cwd);
886
1022
  pi.sendUserMessage(academicGrillRequest(project.goal, "init"), ctx.isIdle() ? {} : { deliverAs: "followUp" });
887
- ctx.ui.notify(`研究项目已初始化:${project.id}(${project.paradigm})。现在开始学术追问;准备好后使用 /run 启动受控研究流程。`, "info");
1023
+ ctx.ui.notify(`研究项目已初始化:${project.id}(${project.paradigm})。学术追问完成并生成主要计划文档后会进入审批;全部批准后才能 /run。`, "info");
888
1024
  }
889
1025
  else {
890
1026
  ctx.ui.notify(`研究项目已初始化:${project.id}(${project.paradigm})。使用 /run 启动受控研究流程。`, "info");
@@ -895,6 +1031,18 @@ export default function psyclawExtension(pi) {
895
1031
  }
896
1032
  },
897
1033
  });
1034
+ if (!legacyTestApi)
1035
+ pi.registerCommand("approve", {
1036
+ description: "审批 /init 生成的主要研究计划文档",
1037
+ handler: async (_args, ctx) => {
1038
+ try {
1039
+ await reviewPrimaryDocuments(ctx);
1040
+ }
1041
+ catch (error) {
1042
+ await notifyError(ctx, error);
1043
+ }
1044
+ },
1045
+ });
898
1046
  if (!legacyTestApi)
899
1047
  pi.registerCommand("run", {
900
1048
  description: "启动受控研究流程,可用 --skills a,b 选择本次优化 Skill",
@@ -919,8 +1067,16 @@ export default function psyclawExtension(pi) {
919
1067
  }
920
1068
  ctx.ui.notify("检测到合规的分析文档,已自动补齐研究项目记录并启动受控流程。", "info");
921
1069
  }
1070
+ const planApproval = await primaryPlanApprovalStatus(ctx.cwd);
1071
+ if (!planApproval.ok) {
1072
+ const missing = planApproval.documents.filter((item) => !item.approved).map((item) => item.path);
1073
+ ctx.ui.notify(`主要计划尚未批准或批准后已修改:${missing.join("、")}。请运行 /approve。`, "warning");
1074
+ return;
1075
+ }
922
1076
  const available = await selectableRunSkills(ctx.cwd);
923
1077
  let requestedSkills = parsed.requestedSkills;
1078
+ if (parsed.mode === "auto" && requestedSkills === undefined)
1079
+ requestedSkills = [];
924
1080
  if (requestedSkills === undefined && ctx.hasUI && typeof ctx.ui.input === "function") {
925
1081
  const answer = await ctx.ui.input("选择本次 Run 使用的 Skill(逗号分隔,可留空)", available.map((skill) => skill.name).join(", "));
926
1082
  if (answer === undefined) {
@@ -931,10 +1087,24 @@ export default function psyclawExtension(pi) {
931
1087
  }
932
1088
  const selectedSkills = resolveRunSkills(requestedSkills ?? [], available);
933
1089
  const objective = parsed.objective || project.goal;
934
- await activateControlledRun(ctx.cwd, project.id, objective, selectedSkills);
935
- pi.appendEntry("psyclaw:controlled-run", { projectId: project.id, objective, selectedSkills, activatedAt: new Date().toISOString() });
936
- pi.sendUserMessage(controlledRunRequest(objective, selectedSkills), ctx.isIdle() ? {} : { deliverAs: "followUp" });
937
- ctx.ui.notify(`受控研究流程已启动${selectedSkills.length > 0 ? `;本次使用 Skill:${selectedSkills.join(", ")}` : ";使用默认 Skill"}。分析完成后会先询问是否撰写论文。`, "info");
1090
+ const runId = `run_${Date.now()}`;
1091
+ await appendApproval(ctx.cwd, {
1092
+ kind: "run-mode",
1093
+ nodeId: "run-mode",
1094
+ decision: parsed.mode === "auto" ? "auto-approved" : "approved",
1095
+ actor: parsed.mode === "auto" ? "auto" : "human",
1096
+ runId,
1097
+ sha256: approvalInputDigest({ projectId: project.id, objective, selectedSkills, mode: parsed.mode }),
1098
+ summary: parsed.mode === "auto" ? "用户选择自动运行模式" : "用户选择人在环路模式",
1099
+ });
1100
+ if (!(await approveRunNodes(ctx, runId, parsed.mode))) {
1101
+ ctx.ui.notify("运行审批被拒绝或超时,未启动。", "warning");
1102
+ return;
1103
+ }
1104
+ await activateControlledRun(ctx.cwd, runId, project.id, objective, selectedSkills, parsed.mode);
1105
+ pi.appendEntry("psyclaw:controlled-run", { runId, projectId: project.id, objective, selectedSkills, mode: parsed.mode, activatedAt: new Date().toISOString() });
1106
+ pi.sendUserMessage(controlledRunRequest(objective, selectedSkills, parsed.mode), ctx.isIdle() ? {} : { deliverAs: "followUp" });
1107
+ ctx.ui.notify(`${parsed.mode === "auto" ? "自动" : "人在环路"}研究流程已启动${selectedSkills.length > 0 ? `;本次使用 Skill:${selectedSkills.join(", ")}` : ";使用默认 Skill"}。`, "info");
938
1108
  }
939
1109
  catch (error) {
940
1110
  await notifyError(ctx, error);
@@ -1072,27 +1242,73 @@ export default function psyclawExtension(pi) {
1072
1242
  },
1073
1243
  });
1074
1244
  if (!legacyTestApi)
1075
- pi.registerCommand("skills", {
1076
- description: "打开 Skill 安装与启用管理页",
1245
+ pi.registerCommand("skill", {
1246
+ description: "管理和安装 Skill",
1077
1247
  handler: async (args, ctx) => {
1078
- try {
1079
- await showSkillManager(pi, args, ctx);
1248
+ const [name, ...rest] = args.trim().split(/\s+/).filter(Boolean);
1249
+ if (!name) {
1250
+ try {
1251
+ await showSkillManager(pi, "", ctx);
1252
+ }
1253
+ catch (error) {
1254
+ await notifyError(ctx, error);
1255
+ }
1256
+ return;
1080
1257
  }
1081
- catch (error) {
1082
- await notifyError(ctx, error);
1258
+ if (name === "install") {
1259
+ const source = rest.join(" ").trim();
1260
+ if (!source) {
1261
+ ctx.ui.notify("Usage: /skill install <local-directory>", "info");
1262
+ return;
1263
+ }
1264
+ try {
1265
+ const installed = await installLocalSkill(source, ctx.cwd);
1266
+ ctx.ui.notify(`已安装本地 Skill ${installed.name}:${installed.target}。请执行 /reload。`, "info");
1267
+ }
1268
+ catch (error) {
1269
+ await notifyError(ctx, error);
1270
+ }
1271
+ return;
1272
+ }
1273
+ if (["status", "enable", "disable", "enable-all", "disable-all"].includes(name)) {
1274
+ try {
1275
+ await showSkillManager(pi, args, ctx);
1276
+ }
1277
+ catch (error) {
1278
+ await notifyError(ctx, error);
1279
+ }
1280
+ return;
1083
1281
  }
1282
+ ctx.ui.notify("Usage: /skill [status|enable <id>|disable <id>|enable-all|disable-all|install <local-directory>]", "info");
1084
1283
  },
1085
1284
  });
1086
1285
  if (!legacyTestApi)
1087
- pi.registerCommand("skill", {
1088
- description: "调用任意已加载 Skill(/skill <name> [任务])",
1286
+ pi.registerCommand("plugin", {
1287
+ description: "管理 Plugin / Extension",
1089
1288
  handler: async (args, ctx) => {
1090
- const [name, ...rest] = args.trim().split(/\s+/).filter(Boolean);
1091
- if (!name || !/^[a-z0-9-]+$/.test(name)) {
1092
- ctx.ui.notify("Usage: /skill <name> [task];也可直接使用 /skill:<name>", "info");
1093
- return;
1289
+ try {
1290
+ const [action, ...rest] = args.trim().split(/\s+/).filter(Boolean);
1291
+ if (!action) {
1292
+ ctx.ui.notify("Usage: /plugin list | install <source> [-l] | remove <source> [-l]", "info");
1293
+ return;
1294
+ }
1295
+ if (!["list", "install", "remove"].includes(action)) {
1296
+ throw new Error("Usage: /plugin list | install <source> [-l] | remove <source> [-l]");
1297
+ }
1298
+ if (action !== "list" && rest.length === 0)
1299
+ throw new Error(`Usage: /plugin ${action} <source> [-l]`);
1300
+ if (action !== "list") {
1301
+ const approved = await ctx.ui.confirm(`${action === "install" ? "安装" : "移除"} Plugin?`, `${rest.join(" ")}\nPlugin 与宿主进程同权限,操作完成后需要重启 PsyClaw。`);
1302
+ if (!approved)
1303
+ return;
1304
+ }
1305
+ await runPluginCommand([action, ...rest]);
1306
+ if (action !== "list")
1307
+ ctx.ui.notify("Plugin 配置已更新,请重启 PsyClaw。", "info");
1308
+ }
1309
+ catch (error) {
1310
+ await notifyError(ctx, error);
1094
1311
  }
1095
- pi.sendUserMessage(`/skill:${name}${rest.length ? ` ${rest.join(" ")}` : ""}`, ctx.isIdle() ? {} : { deliverAs: "followUp" });
1096
1312
  },
1097
1313
  });
1098
1314
  if (!legacyTestApi)
@@ -1118,7 +1334,7 @@ export default function psyclawExtension(pi) {
1118
1334
  const skillLines = skills.items.slice(0, 8).map((item) => `Skill: ${String(item.id)} — ${String(item.name)}`);
1119
1335
  const mcpLines = mcps.items.slice(0, 8).map((item) => `MCP: ${String(item.id)} — ${String(item.name)}`);
1120
1336
  const toolLines = skills.externalTools.slice(0, 8).map((item) => `外部工具: ${String(item.name)} — ${String(item.sourceRef ?? "请查看项目文档")}`);
1121
- ctx.ui.notify(["推荐安装入口", "", ...skillLines, ...mcpLines, "", ...toolLines, "", "安装:/install skill|mcp <id>", "管理:/skills 或 /mcp", "外部工具不通过 Skill 安装器安装", "也可以打开 /panel 查看推荐页面"].join("\n"), "info");
1337
+ ctx.ui.notify(["推荐安装入口", "", ...skillLines, ...mcpLines, "", ...toolLines, "", "安装:/install skill|mcp <id>", "管理:/skill 或 /mcp", "外部工具不通过 Skill 安装器安装", "也可以打开 /panel 查看推荐页面"].join("\n"), "info");
1122
1338
  return;
1123
1339
  }
1124
1340
  if (kind !== "skill" && kind !== "mcp")
@@ -1202,11 +1418,26 @@ export default function psyclawExtension(pi) {
1202
1418
  selectedId = choice;
1203
1419
  }
1204
1420
  if (preset && ctx.hasUI && typeof ctx.ui.custom === "function") {
1421
+ const credential = await providerCredentialSource(preset);
1422
+ // Always show the credential step when switching a built-in
1423
+ // provider. An empty submission deliberately keeps an existing
1424
+ // environment/auth-store credential; this makes the flow explicit
1425
+ // and avoids silently skipping the key screen on another machine.
1205
1426
  const key = await promptProviderKey(ctx, preset.name, preset.apiKeyEnv);
1206
1427
  if (key === undefined)
1207
1428
  return;
1429
+ if (!key && credential === "missing") {
1430
+ throw new Error(`未找到 ${preset.apiKeyEnv};请输入 API Key 后再继续`);
1431
+ }
1208
1432
  await saveProviderConfig({ ...preset, ...(key ? { apiKey: key } : {}) });
1209
- await ctx.modelRegistry.refresh();
1433
+ const refreshed = await Promise.race([
1434
+ ctx.modelRegistry.refresh().then(() => true),
1435
+ new Promise((resolve) => setTimeout(() => resolve(false), 5_000)),
1436
+ ]);
1437
+ if (!refreshed) {
1438
+ ctx.ui.notify("Provider 配置已保存;模型目录刷新超时,请重新启动 PsyClaw 后使用。", "warning");
1439
+ return;
1440
+ }
1210
1441
  models = ctx.modelRegistry.getAll().filter((model) => model.provider === requested);
1211
1442
  }
1212
1443
  const selected = models.find((model) => model.id === selectedId);
@@ -1240,27 +1471,6 @@ export default function psyclawExtension(pi) {
1240
1471
  ctx.ui.notify(`启动横幅宠物已${action === "on" ? "开启" : "关闭"},下次启动生效`, "info");
1241
1472
  },
1242
1473
  });
1243
- const traceCommand = {
1244
- description: "导出包含正文与工具参数的完整使用路径,供 Langfuse 或 LangSmith 分析",
1245
- handler: async (args, ctx) => {
1246
- try {
1247
- if (args.trim())
1248
- throw new Error("Usage: /export");
1249
- const result = await exportTraces({ root: ctx.cwd });
1250
- ctx.ui.notify([
1251
- "使用路径已导出(未上传)",
1252
- `文件:${result.output}`,
1253
- `轨迹:${result.traces}`,
1254
- `步骤:${result.spans}`,
1255
- "包含对话正文、工具参数、原始 ID 与绝对路径,便于排查各环节问题;请勿将导出文件提交到公开仓库。",
1256
- ].join("\n"), "info");
1257
- }
1258
- catch (error) {
1259
- await notifyError(ctx, error);
1260
- }
1261
- },
1262
- };
1263
- pi.registerCommand("export", traceCommand);
1264
1474
  if (typeof pi.registerTool === "function") {
1265
1475
  pi.registerTool({
1266
1476
  name: "psyclaw_mcp",
@@ -1529,7 +1739,10 @@ export default function psyclawExtension(pi) {
1529
1739
  }
1530
1740
  },
1531
1741
  });
1532
- if (developerCommands || legacyTestApi)
1742
+ // Read-only multi-agent research is a user-facing capability. Keep the
1743
+ // destructive/developer commands gated, but do not hide the bounded agent
1744
+ // runner behind PSYCLAW_DEVELOPER_COMMANDS in published builds.
1745
+ if (!legacyTestApi)
1533
1746
  pi.registerCommand("agents", {
1534
1747
  description: "运行经批准的只读研究 Agent",
1535
1748
  handler: async (args, ctx) => {
@@ -1556,7 +1769,7 @@ export default function psyclawExtension(pi) {
1556
1769
  ctx.ui.notify("请先使用 /init 初始化研究项目,再运行 /agents", "warning");
1557
1770
  return;
1558
1771
  }
1559
- 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.");
1772
+ 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.");
1560
1773
  if (!approved) {
1561
1774
  ctx.ui.notify("Agent run canceled", "info");
1562
1775
  return;