psyclaw 0.29.9 → 0.29.11

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.
@@ -17,6 +17,9 @@ import { dirname } from "node:path";
17
17
  import { fileURLToPath, pathToFileURL } from "node:url";
18
18
  import { coreSkillNames, enabledRecommendedSkillPaths, normalizeRecommendedSkillId, readRecommendationState, readRecommendedCatalog, recommendedSkillTarget, saveRecommendationState, validateModelInstalledRecommendedSkill, } from "../../skills/recommended.js";
19
19
  import { SkillManagerComponent } from "../../tui/skill-manager.js";
20
+ import { WakeOptionsComponent } from "../../tui/wake-options.js";
21
+ import { applyWakeVerifySync, buildWakePrompt, formatWakeResult, labelsFor, waitForPanelWakeAnswer, } from "../../wake-options/runtime.js";
22
+ import { panelHub } from "../../panel/hub.js";
20
23
  import { enabledLocalSkillPaths, enabledLocalPromptPaths, readUserSkillState, scanLocalSkills, setLocalSkillEnabled, setLocalSkillsEnabled, skillNamesInPaths, installLocalSkill, userSkillId, } from "../../skills/user-skills.js";
21
24
  import { RuntimeMcpRegistry, setUserMcpConfigEnabled, } from "../../integrations/mcp-runtime.js";
22
25
  import { SecretInputComponent, ProviderPickerComponent, } from "../../tui/provider-picker.js";
@@ -2321,6 +2324,133 @@ export default function psyclawExtension(pi) {
2321
2324
  }
2322
2325
  },
2323
2326
  });
2327
+ pi.registerTool({
2328
+ name: "psyclaw_wake_options",
2329
+ label: "唤醒选项",
2330
+ description: "向研究者弹出结构化选择或核对清单勾选(唤醒选项)。当用户在 Panel 中交互时优先弹窗;同时在 CLI 渲染同等选项。适用于:单选/多选决策、核对清单勾选、确认下一步。不要用自由文本「请回复选项编号」替代本工具。",
2331
+ parameters: Type.Object({
2332
+ title: Type.String({ minLength: 1, description: "弹窗标题,例如「选择下一步」或「分析前核对」" }),
2333
+ prompt: Type.Optional(Type.String({ description: "可选说明文字" })),
2334
+ mode: Type.Union([Type.Literal("choice"), Type.Literal("checklist")], {
2335
+ description: "choice=单选弹窗;checklist=可多选勾选(可同步到核对清单)",
2336
+ }),
2337
+ options: Type.Array(Type.Object({
2338
+ id: Type.String({ minLength: 1 }),
2339
+ label: Type.String({ minLength: 1 }),
2340
+ description: Type.Optional(Type.String()),
2341
+ checked: Type.Optional(Type.Boolean()),
2342
+ }), { minItems: 1 }),
2343
+ allowMultiple: Type.Optional(Type.Boolean()),
2344
+ minSelections: Type.Optional(Type.Number()),
2345
+ syncVerify: Type.Optional(Type.Boolean({ description: "checklist 模式下是否写入 .psyclaw/verify-checklist.json(默认 true)" })),
2346
+ timeoutMs: Type.Optional(Type.Number()),
2347
+ }),
2348
+ executionMode: "sequential",
2349
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
2350
+ try {
2351
+ const wakePrompt = buildWakePrompt({
2352
+ title: params.title,
2353
+ mode: params.mode,
2354
+ options: params.options.map((option) => ({
2355
+ id: option.id,
2356
+ label: option.label,
2357
+ ...(option.description === undefined ? {} : { description: option.description }),
2358
+ ...(option.checked === undefined ? {} : { checked: option.checked }),
2359
+ })),
2360
+ ...(params.prompt === undefined ? {} : { prompt: params.prompt }),
2361
+ ...(params.allowMultiple === undefined ? {} : { allowMultiple: params.allowMultiple }),
2362
+ ...(params.minSelections === undefined ? {} : { minSelections: params.minSelections }),
2363
+ ...(params.syncVerify === undefined ? {} : { syncVerify: params.syncVerify }),
2364
+ ...(params.timeoutMs === undefined ? {} : { timeoutMs: params.timeoutMs }),
2365
+ });
2366
+ const timeoutMs = Math.max(5_000, new Date(wakePrompt.expiresAt).getTime() - Date.now());
2367
+ const panelClients = panelHub.subscriberCount();
2368
+ if (ctx.hasUI) {
2369
+ ctx.ui.notify(panelClients > 0
2370
+ ? `唤醒选项已推送到 Panel(${wakePrompt.title});CLI 也可作答`
2371
+ : `唤醒选项:${wakePrompt.title}(Panel 未连接时仅 CLI)`, "info");
2372
+ }
2373
+ const panelPromise = waitForPanelWakeAnswer(wakePrompt, timeoutMs).then((answer) => ({ ...answer, raced: "panel" }));
2374
+ const cliPromise = (async () => {
2375
+ if (!ctx.hasUI || typeof ctx.ui.custom !== "function") {
2376
+ if (panelClients === 0) {
2377
+ return { promptId: wakePrompt.id, selectedIds: [], source: "cancel", raced: "cli" };
2378
+ }
2379
+ await new Promise((resolve) => {
2380
+ if (signal?.aborted)
2381
+ resolve();
2382
+ else
2383
+ signal?.addEventListener("abort", () => resolve(), { once: true });
2384
+ });
2385
+ return { promptId: wakePrompt.id, selectedIds: [], source: "cancel", raced: "cli" };
2386
+ }
2387
+ const uiResult = await ctx.ui.custom((tui, theme, keybindings, done) => new WakeOptionsComponent(wakePrompt.title, wakePrompt.prompt, wakePrompt.mode, wakePrompt.options, Boolean(wakePrompt.allowMultiple), wakePrompt.minSelections ?? 0, tui, theme, keybindings, done), { overlay: true });
2388
+ if (!uiResult || uiResult.type === "cancel") {
2389
+ return { promptId: wakePrompt.id, selectedIds: [], source: "cancel", raced: "cli" };
2390
+ }
2391
+ const selectedIds = uiResult.selectedIds ?? [];
2392
+ return { promptId: wakePrompt.id, selectedIds, source: "cli", raced: "cli" };
2393
+ })();
2394
+ const winner = await Promise.race([panelPromise, cliPromise]);
2395
+ // If CLI answered first, close the Panel waiter so the SSE prompt dismisses.
2396
+ if (winner.raced === "cli") {
2397
+ panelHub.resolveWake({
2398
+ promptId: wakePrompt.id,
2399
+ selectedIds: winner.selectedIds,
2400
+ source: winner.source === "cancel" ? "cancel" : "cli",
2401
+ });
2402
+ }
2403
+ if (signal?.aborted) {
2404
+ return {
2405
+ content: [{ type: "text", text: formatWakeResult({
2406
+ status: "cancelled",
2407
+ source: "cancel",
2408
+ selectedIds: [],
2409
+ selectedLabels: [],
2410
+ panelClients,
2411
+ }) }],
2412
+ details: { status: "cancelled" },
2413
+ isError: true,
2414
+ };
2415
+ }
2416
+ let status = "answered";
2417
+ if (winner.source === "timeout")
2418
+ status = "timeout";
2419
+ else if (winner.source === "cancel")
2420
+ status = "cancelled";
2421
+ const verifySynced = status === "answered"
2422
+ ? await applyWakeVerifySync(ctx.cwd, wakePrompt, winner.selectedIds)
2423
+ : false;
2424
+ const result = {
2425
+ status,
2426
+ source: winner.source,
2427
+ selectedIds: winner.selectedIds,
2428
+ selectedLabels: labelsFor(wakePrompt, winner.selectedIds),
2429
+ panelClients,
2430
+ verifySynced,
2431
+ ...(winner.notes ? { notes: winner.notes } : {}),
2432
+ };
2433
+ if (result.status !== "answered") {
2434
+ return {
2435
+ content: [{ type: "text", text: formatWakeResult(result) }],
2436
+ details: result,
2437
+ isError: true,
2438
+ };
2439
+ }
2440
+ return {
2441
+ content: [{ type: "text", text: formatWakeResult(result) }],
2442
+ details: result,
2443
+ };
2444
+ }
2445
+ catch (error) {
2446
+ return {
2447
+ content: [{ type: "text", text: `唤醒选项失败:${error instanceof Error ? error.message : String(error)}` }],
2448
+ details: { status: "failed" },
2449
+ isError: true,
2450
+ };
2451
+ }
2452
+ },
2453
+ });
2324
2454
  pi.registerTool({
2325
2455
  name: "psyclaw_cite",
2326
2456
  label: "Record a citation use",