diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index de818e29..78e72f70 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -316,6 +316,18 @@ }, "category": "Developer Tools" }, + { + "name": "superpowers-zh", + "source": { + "source": "local", + "path": "./plugins/codex/plugins/superpowers-zh" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Developer Tools" + }, { "name": "updating-gitea-repositories", "source": { diff --git a/README.md b/README.md index 75c61988..8f91f238 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ Codex 插件页的大标题按 marketplace 来源分组,不是按插件 `categ 有些插件是 skill 集合,安装一个 `plugin-id` 后会同时提供多个 `$skill-name`。可安装项和安装后可用的 skills 均以 Codex 的实际输出为准。 +`superpowers-zh` 是 Superpowers 的简体中文版本,提供规划、测试驱动开发、调试和代码审查等工作流。请使用上述插件 ID 安装;其内容跟随中文版正式发版,Codex 组件保持上游声明。 + `docx`、`pdf`、`pptx` 和 `xlsx` 提供简体中文入口与 Windows PowerShell 指引。首次使用先检查 Python、Node、中文字体和实际需要的依赖;Office 转换需要 LibreOffice,PDF 逐页预览使用 PyMuPDF,无需 Poppler。中文文件路径必须加引号,交付前检查缺字、版式与公式结果。 `find-skills` 使用 Vercel Skills CLI 搜索技能,需要 Node.js 与 `npx`。搜索结果里的安装命令适用于其上游生态;安装本市场插件仍使用上述 Codex 命令。 @@ -39,7 +41,7 @@ Codex 插件页的大标题按 marketplace 来源分组,不是按插件 `categ `embedded-app-pc-debug`、`firmware-app-development` 和 `firmware-lowlevel-development` 分别覆盖 Windows 电脑端设备调试、固件应用层开发及 BSP/PSP 等底层开发流程。使用前应确认项目工具链、目标设备和访问权限;构建、设备写入、刷机、升级、上传和发布等操作必须按插件门禁取得明确确认。 -`artemis-automation` 提供 Android 真机或模拟器的自然语言测试、批量用例、失败诊断、历史检索和报告交付流程。市场只分发 Skill;使用前需单独部署并配置兼容的 ARTEMIS MCP、Android 设备和模型网关。 +`artemis-automation` 提供 Android、电脑和跨端测试,以及探索、批量用例、失败诊断和报告交付流程。市场只分发 Skill;使用前需单独部署兼容的 ARTEMIS MCP 和模型网关,并按测试目标配置 Android 设备或授权的电脑插件。 `artemis-case-prep` 根据原始 Android 测试工作簿筛选可自动化用例,保留原表副本并生成七列 ARTEMIS 任务表;它只准备文档,不启动测试。导入前请检查表头与当前 ARTEMIS 版本是否匹配。 diff --git a/config/external-sources.json b/config/external-sources.json index cf505550..9df9474c 100644 --- a/config/external-sources.json +++ b/config/external-sources.json @@ -507,6 +507,37 @@ "LICENSE" ] }, + { + "id": "superpowers-zh", + "repo": "https://github.com/AreChen/superpowers-zh.git", + "releaseChannel": "latest-stable", + "releaseTagSuffix": "zh", + "adapter": "codex-plugin", + "pluginName": "superpowers-zh", + "category": "Developer Tools", + "sourcePath": ".", + "sparseCheckout": [ + ".codex-plugin", + "skills", + "assets/superpowers-small.svg", + "assets/app-icon.png", + "LICENSE" + ], + "include": [ + ".codex-plugin", + "skills", + "assets", + "LICENSE" + ], + "manifestOverrides": { + "name": "superpowers-zh", + "interface": { + "displayName": "Superpowers-zh", + "developerName": "AreChen / Jesse Vincent", + "category": "Developer Tools" + } + } + }, { "id": "caveman", "repo": "https://github.com/JuliusBrussee/caveman.git", @@ -1234,7 +1265,7 @@ ], "brandColor": "#2563EB", "manifestOverrides": { - "description": "使用 ARTEMIS MCP 完成 Android 自动化测试、批量执行、失败诊断、历史检索和报告交付。", + "description": "使用 ARTEMIS MCP 执行 Android、电脑和跨端测试,诊断任务并交付报告。", "author": { "name": "chenmingxuan", "url": "https://git.playones.com/chenmingxuan" @@ -1250,9 +1281,9 @@ "mcp" ], "interface": { - "displayName": "ARTEMIS Android 自动化", - "shortDescription": "通过 ARTEMIS MCP 执行 Android 测试并交付报告。", - "longDescription": "通过 ARTEMIS MCP 在 Android 真机或模拟器上执行自然语言测试、批量用例、失败诊断、历史检索及 Word/Excel 报告交付。插件只分发 Skill;使用前必须单独部署并配置与该版本兼容的 ARTEMIS MCP、Android 设备和所需模型网关。", + "displayName": "ARTEMIS 自动化", + "shortDescription": "通过 ARTEMIS MCP 执行 Android、电脑和跨端测试。", + "longDescription": "通过 ARTEMIS MCP 执行 Android、电脑和跨端测试,支持探索、批量执行、失败诊断及报告交付。插件只分发 Skill;使用前须单独部署兼容的 ARTEMIS MCP 和模型网关,按测试目标配置 Android 设备或授权的电脑插件。", "developerName": "chenmingxuan", "category": "Developer Tools", "capabilities": [ @@ -1261,8 +1292,8 @@ "Write" ], "defaultPrompt": [ - "使用 ARTEMIS Android 自动化检查设备并执行这个测试用例。", - "使用 ARTEMIS Android 自动化批量执行这些用例并导出报告。" + "使用 ARTEMIS 自动化检查测试环境并执行这个用例。", + "使用 ARTEMIS 自动化批量执行这些用例并导出报告。" ], "websiteURL": "https://git.playones.com/chenmingxuan/Artemis-Eapil", "privacyPolicyURL": "https://git.playones.com/chenmingxuan/Artemis-Eapil", @@ -1450,7 +1481,7 @@ "brandColor": "#000000", "manifestOverrides": { "version": "0.2.0", - "description": "Next.js Agent Skills 提供 Cache Components 迁移、优化和 dev loop 验证工作流。", + "description": "Next.js Agent Skills 提供缓存、预取、打包优化和 dev loop 验证工作流。", "author": { "name": "Vercel", "url": "https://github.com/vercel" @@ -1468,8 +1499,8 @@ ], "interface": { "displayName": "Next.js Agent Skills", - "shortDescription": "Next.js Cache Components 迁移、优化和 dev loop 验证工作流。", - "longDescription": "Next.js Agent Skills 现在跟随 Next.js 仓库 canary/skills 版本,包含 next-cache-components-adoption、next-cache-components-optimizer 和 next-dev-loop 三个技能,覆盖 Cache Components 启用迁移、静态壳/即时导航优化,以及通过 Next.js dev MCP 与真实浏览器验证运行时行为。", + "shortDescription": "Next.js 缓存、预取、打包优化与 dev loop 验证工作流。", + "longDescription": "Next.js Agent Skills 跟随 Next.js 仓库 canary/skills,覆盖 Cache Components、部分预取的采用和优化、打包分析,以及通过 Next.js dev MCP 与真实浏览器验证运行时行为。技能数量以上游目录和安装结果为准。", "developerName": "Vercel", "category": "Developer Tools", "capabilities": [ diff --git a/config/external-sources.lock.json b/config/external-sources.lock.json index 28bcacb8..d4937edb 100644 --- a/config/external-sources.lock.json +++ b/config/external-sources.lock.json @@ -42,8 +42,8 @@ "repo": "https://github.com/vercel-labs/skills.git", "ref": "main", "adapter": "claude-skill", - "commit": "a80e4c9d12d607631249d4d7d059f835d4b2f13a", - "syncedAt": "2026-10-21T06:48:34Z" + "commit": "533c7df02f9b4d913fb8eba553aa99d86103d1d4", + "syncedAt": "2026-10-27T07:55:04Z" }, { "id": "generate-agents-md", @@ -108,6 +108,15 @@ "commit": "28b3b8dee8c976df4fa3e94aed4e092ad43fcb03", "syncedAt": "2026-10-26T16:00:00Z" }, + { + "id": "superpowers-zh", + "pluginName": "superpowers-zh", + "repo": "https://github.com/AreChen/superpowers-zh.git", + "ref": "v7.0.1-zh.1", + "adapter": "codex-plugin", + "commit": "a41f784bb852f0c86d4cc891ecdb14d1344aa063", + "syncedAt": "2026-10-27T07:55:04Z" + }, { "id": "caveman", "pluginName": "caveman", @@ -177,8 +186,8 @@ "repo": "https://github.com/mattpocock/skills.git", "ref": "main", "adapter": "skill-collection", - "commit": "5beb35b040e3de97e54fa9e386d7e42d15c295c2", - "syncedAt": "2026-10-21T06:48:34Z" + "commit": "59db609eb1e18aaf59b63284d0e52255aff0c63c", + "syncedAt": "2026-10-27T07:55:04Z" }, { "id": "updating-gitea-repositories", @@ -220,10 +229,10 @@ "id": "artemis-automation", "pluginName": "artemis-automation", "repo": "https://git.playones.com/chenmingxuan/Artemis-Eapil.git", - "ref": "v2.1.0", + "ref": "v2.2.0", "adapter": "claude-skill", - "commit": "83bcfd54d578a76aaea73e059a8547e3dc07ddbd", - "syncedAt": "2026-10-23T16:00:00Z" + "commit": "be6e8c2abddc0905c86feb4a78cbd2b7113c38f1", + "syncedAt": "2026-10-27T07:55:04Z" }, { "id": "embedded-app-pc-debug", @@ -258,8 +267,8 @@ "repo": "https://github.com/vercel/next.js.git", "ref": "canary", "adapter": "skill-collection", - "commit": "b0e1bf792f18c039933d8a9ce9d50d35f930f1be", - "syncedAt": "2026-10-26T16:00:00Z" + "commit": "5988244ce7247f5112f00beb8050860330d93ca0", + "syncedAt": "2026-10-27T07:55:04Z" }, { "id": "shuorenhua", diff --git a/plugins/codex/.agents/plugins/marketplace.json b/plugins/codex/.agents/plugins/marketplace.json index f452998e..d8ca7cbe 100644 --- a/plugins/codex/.agents/plugins/marketplace.json +++ b/plugins/codex/.agents/plugins/marketplace.json @@ -316,6 +316,18 @@ }, "category": "Developer Tools" }, + { + "name": "superpowers-zh", + "source": { + "source": "local", + "path": "./plugins/superpowers-zh" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Developer Tools" + }, { "name": "updating-gitea-repositories", "source": { diff --git a/plugins/codex/plugins/artemis-automation/.codex-plugin/plugin.json b/plugins/codex/plugins/artemis-automation/.codex-plugin/plugin.json index f98fe519..8236163c 100644 --- a/plugins/codex/plugins/artemis-automation/.codex-plugin/plugin.json +++ b/plugins/codex/plugins/artemis-automation/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "artemis-automation", - "version": "2.1.0", - "description": "使用 ARTEMIS MCP 完成 Android 自动化测试、批量执行、失败诊断、历史检索和报告交付。", + "version": "2.2.0", + "description": "使用 ARTEMIS MCP 执行 Android、电脑和跨端测试,诊断任务并交付报告。", "author": { "name": "chenmingxuan", "url": "https://git.playones.com/chenmingxuan" @@ -18,9 +18,9 @@ ], "skills": "./skills/", "interface": { - "displayName": "ARTEMIS Android 自动化", - "shortDescription": "通过 ARTEMIS MCP 执行 Android 测试并交付报告。", - "longDescription": "通过 ARTEMIS MCP 在 Android 真机或模拟器上执行自然语言测试、批量用例、失败诊断、历史检索及 Word/Excel 报告交付。插件只分发 Skill;使用前必须单独部署并配置与该版本兼容的 ARTEMIS MCP、Android 设备和所需模型网关。", + "displayName": "ARTEMIS 自动化", + "shortDescription": "通过 ARTEMIS MCP 执行 Android、电脑和跨端测试。", + "longDescription": "通过 ARTEMIS MCP 执行 Android、电脑和跨端测试,支持探索、批量执行、失败诊断及报告交付。插件只分发 Skill;使用前须单独部署兼容的 ARTEMIS MCP 和模型网关,按测试目标配置 Android 设备或授权的电脑插件。", "developerName": "chenmingxuan", "category": "Developer Tools", "capabilities": [ @@ -29,8 +29,8 @@ "Write" ], "defaultPrompt": [ - "使用 ARTEMIS Android 自动化检查设备并执行这个测试用例。", - "使用 ARTEMIS Android 自动化批量执行这些用例并导出报告。" + "使用 ARTEMIS 自动化检查测试环境并执行这个用例。", + "使用 ARTEMIS 自动化批量执行这些用例并导出报告。" ], "websiteURL": "https://git.playones.com/chenmingxuan/Artemis-Eapil", "privacyPolicyURL": "https://git.playones.com/chenmingxuan/Artemis-Eapil", diff --git a/plugins/codex/plugins/artemis-automation/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/artemis-automation/THIRD_PARTY_SOURCE.json index e720214f..dede38e4 100644 --- a/plugins/codex/plugins/artemis-automation/THIRD_PARTY_SOURCE.json +++ b/plugins/codex/plugins/artemis-automation/THIRD_PARTY_SOURCE.json @@ -1,9 +1,9 @@ { "sourceId": "artemis-automation", "repo": "https://git.playones.com/chenmingxuan/Artemis-Eapil.git", - "ref": "v2.1.0", - "commit": "83bcfd54d578a76aaea73e059a8547e3dc07ddbd", + "ref": "v2.2.0", + "commit": "be6e8c2abddc0905c86feb4a78cbd2b7113c38f1", "adapter": "claude-skill", "sourcePath": "skills/artemis-use", - "syncedAt": "2026-10-23T16:00:00Z" + "syncedAt": "2026-10-27T07:55:04Z" } diff --git a/plugins/codex/plugins/artemis-automation/skills/artemis-use/references/diagnostics.md b/plugins/codex/plugins/artemis-automation/skills/artemis-use/references/diagnostics.md index 4b10d6ae..c6871af4 100644 --- a/plugins/codex/plugins/artemis-automation/skills/artemis-use/references/diagnostics.md +++ b/plugins/codex/plugins/artemis-automation/skills/artemis-use/references/diagnostics.md @@ -10,7 +10,7 @@ | 问题 | inspect action | 要核对的证据 | | --- | --- | --- | -| 长时间没动、API 超时、no tool call | view_requests | 请求开始/结束、模型、阶段、请求ID、重试、是否终止;HTTP 200 不表示输出结束 | +| 长时间没动、API 超时、499、no tool call | view_requests | 请求开始/结束、模型、阶段、请求/尝试/响应/链路编号、重试、取消来源与时限层级;HTTP 200 不表示输出结束,499 不能单独归因 | | token 特别多、缓存是否生效 | view_usage,再按需 view_requests | prompt/completion/cached、调用数、角色/模型分组;未记录字段不能补猜 | | Python、ADB、插件错误 | view_logs | 该任务 stdout/stderr 有限尾部;脱敏文本和实际错误链 | | 点错、未验证、与结论矛盾 | search → view_step_details / view_step_screenshots | 动作前后画面、工具参数与实际回执、窗口和坐标尺寸 | @@ -18,7 +18,7 @@ `offset/limit` 分页,使用返回的 next_offset;view_logs 的 offset 从最新行向前计数,返回每页仍按时间正序,超过尾部读取窗口的旧字节会注明。text_truncated=true 时缩小范围或读取指定步骤,不把截断当成内容缺失。 -`search` 使用 query 或 step_range,不会执行历史动作。`view_summary` 也有分页,不用它一次加载整条长任务。 +`search` 使用 query 或 step_range,不会执行历史动作。`view_summary` 返回 summary Markdown 与 steps.items/total/offset/limit/next_offset,按 next_offset 翻页,不一次加载整条长任务。items 中 step_id 是存储编号,不能用模型口述步号替代。JSON 结果同时保留 structuredContent 与兼容文本;调用严格按工具 schema,MCP 的 task_desc 与 HTTP 的 goal 不能混用,未知参数在执行前报错。 ## 缓存与耗时 @@ -27,7 +27,10 @@ - 按角色/模型分组判断消耗来源,不能把 Checker、视频分析和执行模型混为一个请求。旧数据未记录模型时注明 unknown。 - 请求阶段和重试用于解释等待,时间戳 Unix 秒及 UTC 转成用户当地时区;注明时区。无终止事件或 unknown 不能当成功。 - 区分模型生成耗时、设备/插件操作耗时、排队及用户等待。先找实际重复观测、工具参数膨胀、无进展循环,再建议优化。 -- 最终验收读取 `view_requests` 返回的 `final_reviews`:核对 stage、total/elapsed/remaining_seconds、request_count、retry_count 和 status;用 review_id 关联请求的 final_review 字段。最终验收与中间检查点独立计时。budget_exhausted 是验收总预算耗尽,request_retries_exhausted 是模型请求重试耗尽,cancelled 是停止或指导中断,不能混称为 API 超时。验收耗尽不自动重跑设备操作;流式半截 passed 和验收 completed 都不能代替正式 test_result。 +- `mobile_manage_task(action="status")` 的 `lifecycle` 与网页进度、报告同源:`durations` 分开记录 total、queued、resource_wait、paused、execution、final_review、report_export,缺少记录为 null。execution 包含初始化与整理,不能当作模型生成速度;旧任务分项未知不能补成零。 +- 指导回执的 accepted_at/timestamp 表示接收,consumed_at 表示执行器已读取;是否被实际应用仍需核对后续步骤。pending/reading 是等待或领取中,终态未消费为 unapplied,终态领取后未确认消费为 unconfirmed。接收成功不能表述为“已执行”或“模型已应用”。 +- 最终验收读取 `view_requests` 返回的 `final_reviews`:核对 stage、total/elapsed/remaining_seconds、request_count、retry_count 和 status;用 review_id 关联请求的 final_review 字段。最终验收与中间检查点独立计时。budget_exhausted 是验收总预算耗尽,request_retries_exhausted 是模型请求重试耗尽;cancelled 的具体来源看 cancel_reason,没有该字段或为 unknown 时保留未知,不能混称为 API 超时。验收耗尽不自动重跑设备操作;流式半截 passed 和验收 completed 都不能代替正式 test_result。 +- 取消记录核对 cancel_reason 与 cancel_phase:user_stop、guidance_replaced、task_paused、task_replaced、superseded 和 outer_timeout 分别表示已明确归因的中断。timeout_scope 的 request、transport、outer、final_review 区分单次请求、传输、外层等待和验收总预算。自动重试用 request_id 分组,以 attempt_id、previous_attempt_id 关联;可用的 client_request_id、server_request_id、response_id、server_trace_id 和 traceparent 用于对照服务端日志。HTTP 499 或裸取消不能证明来源,旧记录不补猜。 ## 环境与插件 @@ -39,6 +42,8 @@ 给出具体结论、支持它的任务/步骤/请求编号、影响与缺失证据。执行失败不必然是被测产品缺陷;验收失败也不必然是工具故障。 +报告与证据索引的 evidence_sources 区分实际执行界面与接口、文件、终端等辅助来源。业务后台名称或网址不决定执行平台,UI 截图不能单独证明后端持久状态。引用实际 step_id/call_id/source_id,保留未验证边界;来源清单不是新的验收结论,也不改写 AI 原判或人工复验。 + 用户只问复盘时不重新执行、不改验证结果。用户明确给出人工复验结论时,使用 mobile_manage_task(action="review", trace_id=..., review_status="passed"或"failed", review_reason=用户理由, user_confirmed=true)。AI 自己的分析不能使用这个入口冒充人工判定。 用户需要资料时再 mobile_export_report;已有 report/evidence 查看不会生成 ZIP。交付真实路径或链接,并说明未验证项。 diff --git a/plugins/codex/plugins/artemis-automation/skills/artemis-use/references/plugins.md b/plugins/codex/plugins/artemis-automation/skills/artemis-use/references/plugins.md index a3f67912..9d2c685a 100644 --- a/plugins/codex/plugins/artemis-automation/skills/artemis-use/references/plugins.md +++ b/plugins/codex/plugins/artemis-automation/skills/artemis-use/references/plugins.md @@ -17,6 +17,8 @@ 示例中的插件必须已安装;不要为解决缺插件而调用宿主私有电脑工具代替 ARTEMIS 执行。`computer` 无需 `device_serial`,`mixed` 同时需要手机与插件资源。批次每个用例的 `task_config` 使用相同字段,可混合不同配置和执行目标。 +网页首页的“仅 PC”勾选项与 `runtime_overrides.execution_target="computer"` 等价,Flash、Pro、探索均支持;Android 离线、锁屏或未安装 ADB 不阻止电脑任务。电脑任务仍需有效模型配置及至少一个已启用、已授权并提供画面观察的电脑插件。探索创建时使用 `task_config`,批量 Excel 的“任务配置(JSON)”可直接填写 `{"execution_target":"computer"}`。不要把 `pc_only` 或 `include_devices` 当作提交参数;后者只用于工作区发现。 + 未提供 `plugins` 表示使用该目标下已启用插件的授权默认配置;空数组明确不用插件。只提供插件 ID 和配置名称,版本由服务在入队时固定。安装、授权、连接凭据和资源保护解除由用户在插件页面处理。 插件超时/取消后先查任务状态和错误,不自动重放点击或配置写入。`mobile_workspace.blocked_resources` 非空时说明存在尚未确认停止的资源,不能声称暂停已安全完成。工具返回成功还需从后续画面或结构化读数验证业务结果。 diff --git a/plugins/codex/plugins/find-skills/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/find-skills/THIRD_PARTY_SOURCE.json index 55aab93d..169b2e55 100644 --- a/plugins/codex/plugins/find-skills/THIRD_PARTY_SOURCE.json +++ b/plugins/codex/plugins/find-skills/THIRD_PARTY_SOURCE.json @@ -2,8 +2,8 @@ "sourceId": "find-skills", "repo": "https://github.com/vercel-labs/skills.git", "ref": "main", - "commit": "a80e4c9d12d607631249d4d7d059f835d4b2f13a", + "commit": "533c7df02f9b4d913fb8eba553aa99d86103d1d4", "adapter": "claude-skill", "sourcePath": "skills/find-skills", - "syncedAt": "2026-10-21T06:48:34Z" + "syncedAt": "2026-10-27T07:55:04Z" } diff --git a/plugins/codex/plugins/grill-me/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/grill-me/THIRD_PARTY_SOURCE.json index 3862736d..e6cd5cc3 100644 --- a/plugins/codex/plugins/grill-me/THIRD_PARTY_SOURCE.json +++ b/plugins/codex/plugins/grill-me/THIRD_PARTY_SOURCE.json @@ -2,8 +2,8 @@ "sourceId": "grill-me", "repo": "https://github.com/mattpocock/skills.git", "ref": "main", - "commit": "5beb35b040e3de97e54fa9e386d7e42d15c295c2", + "commit": "59db609eb1e18aaf59b63284d0e52255aff0c63c", "adapter": "skill-collection", "sourcePath": "skills/productivity", - "syncedAt": "2026-10-21T06:48:34Z" + "syncedAt": "2026-10-27T07:55:04Z" } diff --git a/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json b/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json index 2a5c22b5..d8434061 100644 --- a/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json +++ b/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json @@ -3,5 +3,5 @@ "name": "playwright浏览器自动化操作", "version": "20260605", "keySource": "none", - "syncedAt": "2026-10-26T16:03:14Z" + "syncedAt": "2026-10-27T07:58:23Z" } diff --git a/plugins/codex/plugins/next-skills/.codex-plugin/plugin.json b/plugins/codex/plugins/next-skills/.codex-plugin/plugin.json index 08b00cd5..82788832 100644 --- a/plugins/codex/plugins/next-skills/.codex-plugin/plugin.json +++ b/plugins/codex/plugins/next-skills/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "next-skills", "version": "0.2.0", - "description": "Next.js Agent Skills 提供 Cache Components 迁移、优化和 dev loop 验证工作流。", + "description": "Next.js Agent Skills 提供缓存、预取、打包优化和 dev loop 验证工作流。", "author": { "name": "Vercel", "url": "https://github.com/vercel" @@ -20,8 +20,8 @@ "skills": "./skills/", "interface": { "displayName": "Next.js Agent Skills", - "shortDescription": "Next.js Cache Components 迁移、优化和 dev loop 验证工作流。", - "longDescription": "Next.js Agent Skills 现在跟随 Next.js 仓库 canary/skills 版本,包含 next-cache-components-adoption、next-cache-components-optimizer 和 next-dev-loop 三个技能,覆盖 Cache Components 启用迁移、静态壳/即时导航优化,以及通过 Next.js dev MCP 与真实浏览器验证运行时行为。", + "shortDescription": "Next.js 缓存、预取、打包优化与 dev loop 验证工作流。", + "longDescription": "Next.js Agent Skills 跟随 Next.js 仓库 canary/skills,覆盖 Cache Components、部分预取的采用和优化、打包分析,以及通过 Next.js dev MCP 与真实浏览器验证运行时行为。技能数量以上游目录和安装结果为准。", "developerName": "Vercel", "category": "Developer Tools", "capabilities": [ diff --git a/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json index d322a086..451a8464 100644 --- a/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json +++ b/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json @@ -2,8 +2,8 @@ "sourceId": "next-skills", "repo": "https://github.com/vercel/next.js.git", "ref": "canary", - "commit": "b0e1bf792f18c039933d8a9ce9d50d35f930f1be", + "commit": "5988244ce7247f5112f00beb8050860330d93ca0", "adapter": "skill-collection", "sourcePath": "skills", - "syncedAt": "2026-10-26T16:00:00Z" + "syncedAt": "2026-10-27T07:55:04Z" } diff --git a/plugins/codex/plugins/superpowers-zh/.codex-plugin/plugin.json b/plugins/codex/plugins/superpowers-zh/.codex-plugin/plugin.json new file mode 100644 index 00000000..ac1e09a3 --- /dev/null +++ b/plugins/codex/plugins/superpowers-zh/.codex-plugin/plugin.json @@ -0,0 +1,48 @@ +{ + "name": "superpowers-zh", + "version": "7.0.1-zh.1", + "description": "面向 Agent 的中文技能框架与软件开发方法论:规划、TDD、系统化调试和协作工作流。", + "author": { + "name": "Jesse Vincent", + "email": "jesse@fsck.com", + "url": "https://github.com/obra" + }, + "homepage": "https://github.com/AreChen/superpowers-zh", + "repository": "https://github.com/AreChen/superpowers-zh", + "license": "MIT", + "keywords": [ + "brainstorming", + "subagent-driven-development", + "skills", + "planning", + "tdd", + "debugging", + "code-review", + "workflow" + ], + "skills": "./skills/", + "hooks": {}, + "interface": { + "displayName": "Superpowers-zh", + "shortDescription": "面向编程 Agent 的规划、TDD、调试与交付工作流", + "longDescription": "使用 Superpowers 中文版引导 Agent 完成头脑风暴、实施规划、测试驱动开发、系统化调试、并行执行、代码审查和分支收尾工作流。", + "developerName": "AreChen / Jesse Vincent", + "category": "Developer Tools", + "capabilities": [ + "Interactive", + "Read", + "Write" + ], + "defaultPrompt": [ + "我有一个想实现的点子。", + "让我们给这个项目添加一个功能。" + ], + "websiteURL": "https://github.com/AreChen/superpowers-zh", + "privacyPolicyURL": "https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement", + "termsOfServiceURL": "https://docs.github.com/en/site-policy/github-terms/github-terms-of-service", + "brandColor": "#F59E0B", + "composerIcon": "./assets/superpowers-small.svg", + "logo": "./assets/app-icon.png", + "screenshots": [] + } +} diff --git a/plugins/codex/plugins/superpowers-zh/LICENSE b/plugins/codex/plugins/superpowers-zh/LICENSE new file mode 100644 index 00000000..abf03903 --- /dev/null +++ b/plugins/codex/plugins/superpowers-zh/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Jesse Vincent + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/codex/plugins/superpowers-zh/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/superpowers-zh/THIRD_PARTY_SOURCE.json new file mode 100644 index 00000000..48013acc --- /dev/null +++ b/plugins/codex/plugins/superpowers-zh/THIRD_PARTY_SOURCE.json @@ -0,0 +1,9 @@ +{ + "sourceId": "superpowers-zh", + "repo": "https://github.com/AreChen/superpowers-zh.git", + "ref": "v7.0.1-zh.1", + "commit": "a41f784bb852f0c86d4cc891ecdb14d1344aa063", + "adapter": "codex-plugin", + "sourcePath": ".", + "syncedAt": "2026-10-27T07:55:04Z" +} diff --git a/plugins/codex/plugins/superpowers-zh/assets/app-icon.png b/plugins/codex/plugins/superpowers-zh/assets/app-icon.png new file mode 100644 index 00000000..25518da4 Binary files /dev/null and b/plugins/codex/plugins/superpowers-zh/assets/app-icon.png differ diff --git a/plugins/codex/plugins/superpowers-zh/assets/superpowers-small.svg b/plugins/codex/plugins/superpowers-zh/assets/superpowers-small.svg new file mode 100644 index 00000000..c514fe33 --- /dev/null +++ b/plugins/codex/plugins/superpowers-zh/assets/superpowers-small.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugins/codex/plugins/superpowers-zh/skills/brainstorming/SKILL.md b/plugins/codex/plugins/superpowers-zh/skills/brainstorming/SKILL.md new file mode 100644 index 00000000..455bee07 --- /dev/null +++ b/plugins/codex/plugins/superpowers-zh/skills/brainstorming/SKILL.md @@ -0,0 +1,164 @@ +--- +name: brainstorming +description: "在进行任何创造性工作之前,你都必须使用此技能——包括创建功能、构建组件、添加功能、修改行为,或规划任何新事物,无论是在软件领域还是其他领域(演讲、企业、装修)。" +--- + +# 头脑风暴 + +你很擅长构建东西。你构建的是你认为人类伙伴想要的东西,而这种判断通常没有你感觉的那么扎实。本技能用于在构建任何东西之前,弄清他们真正想要什么,以及为什么。 + +人们往往还没有完全想清楚自己想要什么。好的问题能帮助他们想清楚。当你理解了原因,就能按照他们自己会选择的方式,做出他们从未提及的那一百个决定。 + +## 首先:你究竟知道什么? + +在回复之前,问问自己:对于他们想要什么,以及为什么想要,我知道什么? + +- **所有重要信息都在请求中**(“把图标改成矢车菊蓝”)。这是一个快速、明确的任务。完成它,并说明你做了什么。 +- **缺少某些不可忽视的信息。** 开始对话。 + +在开始实现之前,你必须依次: + +1. 单独用一条消息列出方案,并让你的人类伙伴选择一个(见“列出方案”)。 +2. 编写设计文档,简短版或完整版均可(见“确定工作规模”)。 +3. 获得他们对该文档的批准。 +4. 调用 superpowers:writing-plans 技能。 + +有两件事不算实现:他们已经同意的探索性试作(见“通过探索性试作(Spike)摸清情况”),以及在他们选择跳过提问之后完成他们的请求。 + +## 先了解,再提问 + +对于已有项目,或当他们已经提供了上下文(规格说明、计划、交接材料、他们指出的文件、先前的对话)时,在发出第一条消息前先深入了解。阅读他们提及的内容,以及与之关联的代码或材料,持续阅读,直到你能解释相关部分目前如何运作,以及这次改动会涉及哪些部分。绝不要询问项目或他们提供的上下文已经回答的问题。 + +## 你的第一条消息 + +用以下一种方式推进工作: + +- **他们已经说了自己想要什么**(规格说明、计划、详细请求):整条消息以大约 200 词为目标。用你自己的话复述他们的请求,让他们看出你是否把握了整体:在他们已经说明的范围内,他们想要什么、为谁而做,以及为什么。补充你发现的相关情况:相关部分目前如何运作,以及这次改动会涉及哪些部分。如果你发现了他们可能未曾考虑、并且会改变他们的请求或构建方式的情况,例如约束、冲突或成本更低的路径,就直白地说出来。如何构建要等到你理解他们想要什么之后再谈(见“列出方案”)。结尾先用一行话请他们纠正,再提出一个关于他们想要什么的问题:请求中尚未明确、且其答案对构建结果影响最大的那一点。规格说明往往并不完整,因此在以它为基础继续推进之前,先加以确认。 +- **他们还没有说:** 用他们自己的说法,针对请求中最大的未决事项,提出一个开放式问题。说明你为什么问:答案会如何改变你构建的东西。对于“给我的面包店做一个网站”:“如果主要是给正在决定要不要来店里的人看,我会优先展示营业时间和今天展示柜里有什么;要接单的话,网站就不一样了。你希望人们来到网站后做什么?” + +消息如何结尾,取决于他们的消息是否提出了头脑风暴的要求: + +- **他们提出了要求**(直接点名、调用此技能,或要求讨论设计):以你的问题结尾。他们想要这场对话。 +- **他们没有提出要求:** 他们可能根本不想对话。在你的问题后,轻松地加上一行,表示也可以直接开始,比如“或者,如果你更愿意让我直接动手,说一声就行。”如果他们选择这样做,本技能就到此结束:用一行直白的话说明在制作方式上作出的重要选择(平台、语言、媒介),然后通过正常流程完成他们的请求。 + +整条消息仅此而已。 + +## 对话 + +每条消息都以一个问题结尾:只用一句话,后面不要附上示例答案列表。使用浅显的语言,难度只比人类伙伴当前的水平稍高一点。沿用他们的用词;绝不要使用流程术语。你无需向你的人类伙伴引用本技能的内容。 + +清晰简洁。先说重要的事。避免行话。报告发现时,给出最重要的发现,并表示可以提供其余内容。 + +**让他们描述。** 先用开放式问题。他们自己的话承载着你的选项无法承载的意图。只有在他们卡住时,才给出一组简短的选项。当他们说“你猜吧”或“你怎么看?”时,提出具体的建议。 + +**如何制作。** 每个项目都建立在对媒介、工具、材料和资源的选择之上:应用的平台、编程语言和库;艺术作品的媒介。及早弄清这些选择中哪些他们想亲自做、哪些交给你,以及他们已经有哪些可用的东西。有明确主见或专长的人会想把这些讨论清楚。依靠你的人需要合理的默认选择,并且需要你用直白的话说出来。已有工作时,在它现有采用的东西上继续构建。你做出的任何选择都应在设计中标明是你自己的决定,绝不能写成他们同意了的内容。 + +**需要覆盖的方面。** 这是探索范围,不是脚本。绝不要把以下内容当作问题念出来: + +- 这是什么类型的东西,以及它有什么特别之处 +- 为什么现在做,以及在他们看来怎样才算成功 +- 目标、非目标和反目标(即使技术上能运行,也会让这件事成为失败的情况) +- 既有参考:他们在别处见过什么,喜欢或讨厌什么 +- 现状:现在有什么,他们尝试过什么 +- 他们在意的细节 + +**每个问题都要具体**,紧扣他们的项目和他们告诉你的内容。像一位敏锐的同事那样,以苏格拉底式的方式提问:每个问题都建立在他们上一个回答或你发现的某件事之上,帮助他们想清楚可能遗漏的后果或情形(“投票在会议前截止,而本月有两名成员加入。在本轮中途加入的人有投票权吗?”)。如果原因还不明显,就说明你希望了解什么,或者答案会改变什么。一个他们可以纠正的建议,加上你提出它的理由,往往比一个问题更有效。 + +**提议去探查。** 有了一些基本了解,而且进一步探查会有所帮助时,提议去看看:更深入地探查他们的代码库或项目,查看他们自己的文件和数据,或者搜索网络。说明你会查找什么。是否让你去,由他们决定。 + +**列出方案。** 一旦理解了他们想要什么,在把任何内容整理成文之前,单独用一条消息列出构建它的不同方式。想出几个真正的备选方案,淘汰薄弱的,包括所有与他们想做这件事的原因相冲突的,以及既有工作不允许采用的方案。展示剩下的两三个方案,每个方案都要以项目实际在做的事情为依据,并说明各自的权衡。推荐一个并说明原因,然后询问他们想走哪条路。当确实只有一种合理的方式时,就明确说明,并解释其他方式为什么不合适。这是针对如何构建而言;至于他们想要什么,先让他们描述。 + +**展示,而非讲述。** 只要看比读更有帮助,就提议使用可视化伴侣(见下文): + +- 他们会看的东西:界面、页面、打印件、标牌 +- 布局或安排:界面、房间、日程 +- 流程或一系列步骤 +- 外观各不相同的选项 +- 比阅读更容易通过观看理解的结构:图表、时间线、地图 + +一旦他们接受,就用它展示供他们反馈的原型图、并排的选项,以及他们可以点击的一次性原型。 + +**通过探索性试作(Spike)摸清情况。** Agent 工作是瀑布式的,但速度非常、非常快。快速构建一个一次性版本,往往是了解他们想要什么或某件事是否行得通的最低成本方式。有帮助时,就提议做一次探索性试作。当他们只想做探索性试作时,不要挡路。探索性试作不写测试或只写最少量的测试,并非万无一失,还会跳过计划、实现和审查流程。把探索性试作所得的认识带回对话。 + +## 复述你的理解 + +随着对话推进不断复述。一旦你对某一部分的理解已经足以描述它(例如,它有什么用、服务于谁,或某一部分如何表现),就用大约 200-300 词把这一部分复述给他们,然后回到关于下一部分的提问。在整个对话中,复述和提问交替进行;最后一段涵盖所有剩余内容。 + +只有他们说过的内容,才能写成他们的想法。凡是你补充的内容,都要标明是你的建议,并附上理由。每段结尾都要问有什么不对或遗漏。 + +发送一段复述之前,先检查:它是否描述了他们会看的东西,比如页面、界面、日程表或打印件?如果是,而且他们还没有看到过可视化伴侣的提议,就改为发送提议(下方所示,单独一条消息),把这段复述留到下一条消息。一旦他们接受,就在描述旁放上一个原型图。 + +## 确定工作规模 + +一旦理解了他们想要什么,而且他们已选定一个方案,就用直白的话说明工作规模。他们可以推翻你的判断。 + +| 规模 | 设计文档 | +|------|----------------| +| 小改动 | 简短版:把你们已经共同讨论过的内容写下来 | +| 项目 | 完整的书面设计(见下文) | + +无论简短版还是完整版,设计文档都要包含一个**方案**部分:你考虑过的两三种构建方式、各自的权衡、他们选择了哪一种,以及原因。他们在对话中作出选择,然后你再编写文档。 + +如果任务中途规模扩大,停下来,说明情况,并升级到更大的规模。 + +如果这实际上是几个相互独立的项目,指出这一点,商定顺序,然后一次对一个项目进行头脑风暴。 + +## 书面设计 + +写一份用直白语言写成的文档,让该领域优秀的实现者无需再询问你的人类伙伴就能据此制定计划。涵盖: + +- 意图及其原因 +- 目标、非目标、反目标 +- 约束 +- 方案(见上文) +- 制作方式中由他们决定的部分,按他们的决定如实记录 +- 留给实现者决定的部分 + +对于小改动,简短版设计对同样的要点各用一两行加以说明,并直接交给你的人类伙伴,无需进行实现者检查。 + +**存放位置:** 在软件仓库中,保存到 +`docs/superpowers/specs/YYYY-MM-DD--design.md` 并提交。 +其他情况,询问他们。人类伙伴的偏好优先于上述两种安排。 + +**实现者检查**(针对项目):使用本目录中的 +`builder-check-prompt.md` 派发一个全新的子 Agent。当你告诉人类伙伴你正在整理成文时,说明会先由一位审查者阅读,并且你可能会再回来一次,问几个问题。检查结果返回前,不要交付文档。把实现者提出的问题分为三类: + +- **已经回答**:对话或项目已给出答案,把答案写入文档。 +- **次要问题:** 一个有合理判断力的实现者能够在不改变构建结果的情况下决定。你自行决定,并在文档中标明这是你的决定。 +- **由他们决定:** 只有你的人类伙伴才能回答,且答案会改变构建结果。 + +把“由他们决定”这一类问题集中在一条消息中提出,最重要的放在前面。根据他们的答案更新文档,然后交付文档,并附上一份你自行作出的决定的简短清单,让他们在阅读时检查这些决定。这是该检查唯一会产生的一轮提问。没有子 Agent 工具时,你自己代入这位实现者的角色来阅读文档。 + + +在人类伙伴批准设计文档之前,不得构建任何东西:小改动使用简短版,项目使用完整版。构建包括编写产品代码、搭建脚手架、安装依赖、创建项目,以及调用实现技能。阅读和探索可以进行。 + +有两个例外。如果他们选择跳过提问,本技能就已结束,关卡也随之取消。可以构建他们已经同意的探索性试作;它始终标记为一次性产物,保留其产出则是一个新请求,必须重新经过这道关卡。 + +设计文档获批之后,下一步是制定计划:调用 superpowers:writing-plans,不得调用其他任何技能。 + + +## 危险信号 + +| 想法 | 事实 | +|---------|---------| +| “我会提供一组他们可能想要的选项” | 选项会引导方向。先让他们描述。 | +| “我会补上合理的默认选择” | 很好。把它们说出来。他们从未听说的默认选择,他们也就无法拒绝。 | +| “我已经回答了他们的问题,所以要开始写文档了” | 先单独用一条消息列出方案。 | +| “我应该先解释流程” | 提出你的问题。流程会自然显现。 | +| “他们对这个想法说了‘听起来不错’” | 批准只涵盖你向他们展示过的内容。还没写出来的设计文档不算获批。 | +| “探索性试作能行,我会继续在它的基础上构建” | 保留它是一个新请求。重新经过关卡。 | + +## 可视化伴侣 + +一个浏览器标签页,用于展示原型图、图表和原型。它是工具,不是模式:接受它并不意味着每个问题都要发送到浏览器。 + +**在恰当时机提议使用它。** 上面提到的情况第一次出现时,就提议使用它,绝不要一开始就提出。该提议应单独作为一条消息,不含任何其他内容: + +> “如果展示给你看,可能更容易理解。要我打开一个浏览器标签页,放上几个原型图吗?” + +如果他们拒绝,则继续仅使用文本,并且不要再次提出使用它,除非他们主动提起。 + +**针对每个问题都要问:通过观看,他们会不会更容易理解?** +原型图、布局、图表和并排的设计放在浏览器中。需求、范围、权衡和概念性选择留在文本中。关于 UI 主题的问题并不自动等同于视觉问题。 + +如果他们接受,请在启动服务器之前阅读本目录中的 `visual-companion.md`。 diff --git a/plugins/codex/plugins/superpowers-zh/skills/brainstorming/builder-check-prompt.md b/plugins/codex/plugins/superpowers-zh/skills/brainstorming/builder-check-prompt.md new file mode 100644 index 00000000..bb9caabf --- /dev/null +++ b/plugins/codex/plugins/superpowers-zh/skills/brainstorming/builder-check-prompt.md @@ -0,0 +1,22 @@ +# 实现者检查提示词 + +书面设计完成后,使用此提示词派发一个全新的通用子 Agent。 +填入领域和文档路径。 + +``` +你是一位才华出众的 [DOMAIN,例如 "软件工程师"、"活动策划师"、 +"面包店顾问"]。你拿到了一份设计文档,并被要求制定计划, +实现文档中描述的内容。你无法与撰写该文档的人沟通。 + +阅读 [DOCUMENT_PATH]。 + +只报告在能够妥善制定计划之前,你仍需要得到解答的问题: +也就是那些你不得不猜测,而猜错又会产生实质影响的地方。 +对于每个问题,给出问题本身,并说明答案为何会改变计划。 +按重要性排序。 + +跳过文档明确留给实现者决定的事项,以及任何你能够合理地 +自行决定的事项。不要挑剔措辞。 + +如果没有剩余的重要问题,就说 "没有剩余的重要问题"。 +``` diff --git a/plugins/codex/plugins/superpowers-zh/skills/brainstorming/scripts/frame-template.html b/plugins/codex/plugins/superpowers-zh/skills/brainstorming/scripts/frame-template.html new file mode 100644 index 00000000..b1cae3f1 --- /dev/null +++ b/plugins/codex/plugins/superpowers-zh/skills/brainstorming/scripts/frame-template.html @@ -0,0 +1,213 @@ + + + + + Superpowers 头脑风暴 + + + +
+ +
正在连接…
+
+ +
+
+ +
+
+ + + diff --git a/plugins/codex/plugins/superpowers-zh/skills/brainstorming/scripts/helper.js b/plugins/codex/plugins/superpowers-zh/skills/brainstorming/scripts/helper.js new file mode 100644 index 00000000..7987b3af --- /dev/null +++ b/plugins/codex/plugins/superpowers-zh/skills/brainstorming/scripts/helper.js @@ -0,0 +1,167 @@ +(function() { + const MIN_RECONNECT_MS = 500; + const MAX_RECONNECT_MS = 30000; + const TOMBSTONE_AFTER_MS = 15000; // show the "paused" overlay after this long disconnected + + // Pure: next backoff delay (doubles, capped). Exported for unit tests. + function nextReconnectDelay(current, max) { + return Math.min(current * 2, max); + } + if (typeof module !== 'undefined' && module.exports) { + module.exports = { nextReconnectDelay, MIN_RECONNECT_MS, MAX_RECONNECT_MS, TOMBSTONE_AFTER_MS }; + } + + // Everything below is browser-only; bail out when loaded in Node (tests). + if (typeof window === 'undefined') return; + + let ws = null; + let eventQueue = []; + let reconnectDelay = MIN_RECONNECT_MS; + let reconnectTimer = null; + let disconnectedSince = null; + let everConnected = false; + let tombstoneShown = false; + + function sessionKey() { + try { + return window.sessionStorage && window.sessionStorage.getItem('brainstorm-session-key'); + } catch (e) {} + return null; + } + + function websocketUrl() { + const key = sessionKey(); + return 'ws://' + window.location.host + (key ? '/?key=' + encodeURIComponent(key) : ''); + } + + function reloadAfterRecovery() { + const key = sessionKey(); + if (key) { + window.location.replace('/?key=' + encodeURIComponent(key)); + } else { + window.location.reload(); + } + } + + // Reflect connection state in the frame's status pill (absent on full-doc screens). + function setStatus(state) { + const el = document.querySelector('.status'); + if (!el) return; + const map = { + connecting: ['正在连接…', 'var(--text-tertiary)'], + connected: ['已连接', 'var(--success)'], + reconnecting: ['正在重新连接…', 'var(--warning)'], + disconnected: ['已断开连接', 'var(--error)'] + }; + const [text, color] = map[state] || map.disconnected; + el.textContent = text; + el.style.setProperty('--status-color', color); + } + + // Self-styled so it works on framed and full-document screens alike. + function showTombstone() { + if (tombstoneShown) return; + tombstoneShown = true; + const el = document.createElement('div'); + el.id = 'bs-tombstone'; + el.style.cssText = 'position:fixed;inset:0;z-index:99999;display:flex;' + + 'align-items:center;justify-content:center;padding:2rem;text-align:center;' + + 'background:rgba(20,20,22,0.92);color:#f5f5f7;font-family:system-ui,sans-serif'; + el.innerHTML = '
' + + '

伴侣已暂停

' + + '

头脑风暴伴侣已停止。' + + '请让你的编程 Agent 重新启动它——本页面会自动重新连接。

'; + if (document.body) document.body.appendChild(el); + } + + function connect() { + if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } + setStatus(everConnected ? 'reconnecting' : 'connecting'); + ws = new WebSocket(websocketUrl()); + + ws.onopen = () => { + const recovered = tombstoneShown; + everConnected = true; + disconnectedSince = null; + reconnectDelay = MIN_RECONNECT_MS; + tombstoneShown = false; + setStatus('connected'); + eventQueue.forEach(e => ws.send(JSON.stringify(e))); + eventQueue = []; + // Recovered from a tombstoned outage (e.g. the server restarted on the same + // port) — reload through the keyed bootstrap when possible so the cookie is + // refreshed before the visible URL returns to bare /. + if (recovered) reloadAfterRecovery(); + }; + + ws.onmessage = (msg) => { + let data; + try { data = JSON.parse(msg.data); } catch (e) { return; } + if (data.type === 'reload') window.location.reload(); + }; + + ws.onclose = () => { + ws = null; + if (disconnectedSince === null) disconnectedSince = Date.now(); + if (Date.now() - disconnectedSince >= TOMBSTONE_AFTER_MS) { + setStatus('disconnected'); + showTombstone(); + } else { + setStatus('reconnecting'); + } + reconnectTimer = setTimeout(connect, reconnectDelay); + reconnectDelay = nextReconnectDelay(reconnectDelay, MAX_RECONNECT_MS); + }; + + // Let onclose own reconnection so we don't schedule it twice. + ws.onerror = () => { try { ws.close(); } catch (e) {} }; + } + + function sendEvent(event) { + event.timestamp = Date.now(); + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(event)); + } else { + eventQueue.push(event); + } + } + + // Capture clicks on choice elements + document.addEventListener('click', (e) => { + const target = e.target.closest('[data-choice]'); + if (!target) return; + + sendEvent({ + type: 'click', + text: target.textContent.trim(), + choice: target.dataset.choice, + id: target.id || null + }); + + }); + + // Frame UI: selection tracking + window.selectedChoice = null; + + window.toggleSelect = function(el) { + const container = el.closest('.options') || el.closest('.cards'); + const multi = container && container.dataset.multiselect !== undefined; + if (container && !multi) { + container.querySelectorAll('.option, .card').forEach(o => o.classList.remove('selected')); + } + if (multi) { + el.classList.toggle('selected'); + } else { + el.classList.add('selected'); + } + window.selectedChoice = el.dataset.choice; + }; + + // Expose API for explicit use + window.brainstorm = { + send: sendEvent, + choice: (value, metadata = {}) => sendEvent({ type: 'choice', value, ...metadata }) + }; + + connect(); +})(); diff --git a/plugins/codex/plugins/superpowers-zh/skills/brainstorming/scripts/server.cjs b/plugins/codex/plugins/superpowers-zh/skills/brainstorming/scripts/server.cjs new file mode 100644 index 00000000..cc6c2aee --- /dev/null +++ b/plugins/codex/plugins/superpowers-zh/skills/brainstorming/scripts/server.cjs @@ -0,0 +1,723 @@ +const crypto = require('crypto'); +const http = require('http'); +const fs = require('fs'); +const path = require('path'); + +// ========== WebSocket Protocol (RFC 6455) ========== + +const OPCODES = { TEXT: 0x01, CLOSE: 0x08, PING: 0x09, PONG: 0x0A }; +const WS_MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; +const MAX_FRAME_PAYLOAD_BYTES = 10 * 1024 * 1024; + +function computeAcceptKey(clientKey) { + return crypto.createHash('sha1').update(clientKey + WS_MAGIC).digest('base64'); +} + +function encodeFrame(opcode, payload) { + const fin = 0x80; + const len = payload.length; + let header; + + if (len < 126) { + header = Buffer.alloc(2); + header[0] = fin | opcode; + header[1] = len; + } else if (len < 65536) { + header = Buffer.alloc(4); + header[0] = fin | opcode; + header[1] = 126; + header.writeUInt16BE(len, 2); + } else { + header = Buffer.alloc(10); + header[0] = fin | opcode; + header[1] = 127; + header.writeBigUInt64BE(BigInt(len), 2); + } + + return Buffer.concat([header, payload]); +} + +function decodeFrame(buffer) { + if (buffer.length < 2) return null; + + const secondByte = buffer[1]; + const opcode = buffer[0] & 0x0F; + const masked = (secondByte & 0x80) !== 0; + let payloadLen = secondByte & 0x7F; + let offset = 2; + + if (!masked) throw new Error('Client frames must be masked'); + + if (payloadLen === 126) { + if (buffer.length < 4) return null; + payloadLen = buffer.readUInt16BE(2); + offset = 4; + } else if (payloadLen === 127) { + if (buffer.length < 10) return null; + const extendedLen = buffer.readBigUInt64BE(2); + if (extendedLen > BigInt(MAX_FRAME_PAYLOAD_BYTES)) { + throw new Error('WebSocket frame payload exceeds maximum allowed size'); + } + payloadLen = Number(extendedLen); + offset = 10; + } + + if (payloadLen > MAX_FRAME_PAYLOAD_BYTES) { + throw new Error('WebSocket frame payload exceeds maximum allowed size'); + } + + const maskOffset = offset; + const dataOffset = offset + 4; + const totalLen = dataOffset + payloadLen; + if (buffer.length < totalLen) return null; + + const mask = buffer.slice(maskOffset, dataOffset); + const data = Buffer.alloc(payloadLen); + for (let i = 0; i < payloadLen; i++) { + data[i] = buffer[dataOffset + i] ^ mask[i % 4]; + } + + return { opcode, payload: data, bytesConsumed: totalLen }; +} + +// ========== Configuration ========== + +const PORT_FILE = process.env.BRAINSTORM_PORT_FILE || null; +const randomPort = () => 49152 + Math.floor(Math.random() * 16383); +// Prefer an explicit port, else the port this session last bound (so a restart +// reuses it and an already-open browser tab reconnects), else a random high port. +function preferredPort() { + if (process.env.BRAINSTORM_PORT) return Number(process.env.BRAINSTORM_PORT); + if (PORT_FILE) { + try { + const p = Number(fs.readFileSync(PORT_FILE, 'utf-8').trim()); + if (Number.isInteger(p) && p > 1023 && p < 65536) return p; + } catch (e) { /* no prior port recorded */ } + } + return randomPort(); +} +let PORT = preferredPort(); +const HOST = process.env.BRAINSTORM_HOST || '127.0.0.1'; +const URL_HOST = process.env.BRAINSTORM_URL_HOST || (HOST === '127.0.0.1' ? 'localhost' : HOST); +const SESSION_DIR = process.env.BRAINSTORM_DIR || '/tmp/brainstorm'; +const CONTENT_DIR = path.join(SESSION_DIR, 'content'); +const STATE_DIR = path.join(SESSION_DIR, 'state'); +const SUPERPOWERS_VERSION = readSuperpowersVersion(); +const SUPERPOWERS_BRAND_IMAGE_URL = 'https://primeradiant.com/brand/superpowers-visual-brainstorming-logo.png'; +const TELEMETRY_DISABLE_ENV_VARS = [ + 'SUPERPOWERS_DISABLE_TELEMETRY', + 'DISABLE_TELEMETRY', + 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC' +]; +const SUPERPOWERS_TELEMETRY_DISABLED = TELEMETRY_DISABLE_ENV_VARS.some(name => isTruthyEnv(process.env[name])); +let ownerPid = process.env.BRAINSTORM_OWNER_PID ? Number(process.env.BRAINSTORM_OWNER_PID) : null; + +// Per-session secret key. The companion is reachable by any local browser tab +// and, when bound to a non-loopback host, by any host that can route to it. +// The key authenticates the real client uniformly across loopback, tunnel, and +// remote binds — and defeats DNS rebinding — where a Host/Origin allowlist +// cannot. It rides the served URL as ?key= and is mirrored into a cookie on +// first load so same-origin subresources and the WebSocket carry it for free. +// Persisted alongside the port (BRAINSTORM_TOKEN_FILE) so a restart keeps the +// same key and an already-open tab's cookie still validates. +const TOKEN_FILE = process.env.BRAINSTORM_TOKEN_FILE || null; +function generateToken() { + return crypto.randomBytes(32).toString('hex'); +} + +function chmodOwnerOnly(file) { + try { fs.chmodSync(file, 0o600); } catch (e) { /* best effort */ } +} + +function initialToken() { + if (process.env.BRAINSTORM_TOKEN) { + return { value: process.env.BRAINSTORM_TOKEN, source: 'env' }; + } + if (TOKEN_FILE) { + try { + const t = fs.readFileSync(TOKEN_FILE, 'utf-8').trim(); + if (/^[0-9a-f]{32,}$/i.test(t)) { + chmodOwnerOnly(TOKEN_FILE); + return { value: t, source: 'file' }; + } + } catch (e) { /* no prior token recorded */ } + } + return { value: generateToken(), source: 'generated' }; +} + +const tokenInfo = initialToken(); +let TOKEN = tokenInfo.value; +let tokenSource = tokenInfo.source; +let COOKIE_NAME = 'brainstorm-key-' + PORT; // refined to the actual bound port in onListen + +const MIME_TYPES = { + '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript', + '.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml' +}; + +// ========== Templates and Constants ========== + +function waitingPage() { + return renderBranding(` + +头脑风暴伴侣 + + +

头脑风暴伴侣

+

正在等待 Agent 推送界面…

`); +} + +const FORBIDDEN_PAGE = ` + +需要会话密钥 + + +

需要会话密钥

+

本页面需要编程 Agent 提供的完整 URL,其中必须包含 +?key=… 部分。请复制完整 URL 后重新打开。

`; + +function bootstrapPage(key) { + const jsonKey = JSON.stringify(String(key)); + return ` + +正在打开头脑风暴伴侣 + + + +`; +} + +const frameTemplate = fs.readFileSync(path.join(__dirname, 'frame-template.html'), 'utf-8'); +const helperScript = fs.readFileSync(path.join(__dirname, 'helper.js'), 'utf-8'); +const helperInjection = ''; + +// ========== Helper Functions ========== + +function readSuperpowersVersion() { + const root = path.join(__dirname, '../../..'); + const manifests = [ + path.join(root, 'package.json'), + path.join(root, '.codex-plugin/plugin.json') + ]; + + for (const manifest of manifests) { + try { + const data = JSON.parse(fs.readFileSync(manifest, 'utf-8')); + if (data.version) return String(data.version); + } catch (e) { + // Packaged Codex plugins omit package.json; try the next manifest. + } + } + + return 'unknown'; +} + +function isTruthyEnv(value) { + if (!value) return false; + const normalized = String(value).trim().toLowerCase(); + if (!normalized) return false; + return !['0', 'false', 'no', 'off'].includes(normalized); +} + +function escapeHtmlText(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function brandMarkup() { + const version = escapeHtmlText(SUPERPOWERS_VERSION); + const text = SUPERPOWERS_TELEMETRY_DISABLED + ? 'Prime Radiant Superpowers v' + version + : 'Superpowers v' + version; + const logo = SUPERPOWERS_TELEMETRY_DISABLED + ? '' + : ''; + + return '
' + logo + '' + text + '
'; +} + +function renderBranding(html) { + return html.split('').join(brandMarkup()); +} + +function isFullDocument(html) { + const trimmed = html.trimStart().toLowerCase(); + return trimmed.startsWith('', () => content); +} + +function getNewestScreen() { + const files = fs.readdirSync(CONTENT_DIR) + .filter(f => !f.startsWith('.') && f.endsWith('.html')) + .map(f => { + const fp = path.join(CONTENT_DIR, f); + if (!isRegularFileInsideContentDir(fp)) return null; + return { path: fp, mtime: fs.statSync(fp).mtime.getTime() }; + }) + .filter(Boolean) + .sort((a, b) => b.mtime - a.mtime); + return files.length > 0 ? files[0].path : null; +} + +function urlHostForHttp(host) { + const h = String(host); + if (h.startsWith('[') && h.endsWith(']')) return h; + return h.includes(':') ? '[' + h + ']' : h; +} + +function companionUrl() { + return 'http://' + urlHostForHttp(URL_HOST) + ':' + PORT + '/?key=' + TOKEN; +} + +function browserLauncherForPlatform(url, { + platform = process.platform, + osRelease = require('os').release(), + env = process.env +} = {}) { + const isWSL = platform === 'linux' && /microsoft/i.test(osRelease); + if (platform === 'darwin') return { bin: 'open', args: [url] }; + if (platform === 'win32' || isWSL) { + return { bin: 'rundll32.exe', args: ['url.dll,FileProtocolHandler', url] }; + } + if (env.DISPLAY || env.WAYLAND_DISPLAY) return { bin: 'xdg-open', args: [url] }; + return null; +} + +function isRegularFileInsideContentDir(filePath) { + let stat, realContentDir, realFilePath; + try { + stat = fs.lstatSync(filePath); + if (stat.isSymbolicLink()) return false; + if (!stat.isFile()) return false; + if (stat.nlink !== 1) return false; + realContentDir = fs.realpathSync(CONTENT_DIR); + realFilePath = fs.realpathSync(filePath); + } catch (e) { + return false; + } + return realFilePath.startsWith(realContentDir + path.sep); +} + +// ========== Authentication ========== + +function timingSafeEqualStr(a, b) { + const ab = Buffer.from(String(a)); + const bb = Buffer.from(String(b)); + if (ab.length !== bb.length) return false; + return crypto.timingSafeEqual(ab, bb); +} + +function parseCookies(header) { + const out = {}; + if (!header) return out; + for (const part of header.split(';')) { + const eq = part.indexOf('='); + if (eq < 0) continue; + out[part.slice(0, eq).trim()] = part.slice(eq + 1).trim(); + } + return out; +} + +// A request is authorized if it carries the session key as ?key= or as the +// session cookie. Both are compared in constant time. +function isAuthorized(req) { + const q = req.url.indexOf('?'); + if (q >= 0) { + const params = new URLSearchParams(req.url.slice(q + 1)); + if (params.has('key')) { + const key = params.get('key'); + return Boolean(key && timingSafeEqualStr(key, TOKEN)); + } + } + const cookie = parseCookies(req.headers['cookie'])[COOKIE_NAME]; + if (cookie && timingSafeEqualStr(cookie, TOKEN)) return true; + return false; +} + +function pathnameOf(url) { + const q = url.indexOf('?'); + return q >= 0 ? url.slice(0, q) : url; +} + +function queryKey(url) { + const q = url.indexOf('?'); + if (q < 0) return null; + return new URLSearchParams(url.slice(q + 1)).get('key'); +} + +function securityHeaders(headers = {}) { + return { + 'Referrer-Policy': 'no-referrer', + 'Cache-Control': 'no-store', + 'X-Frame-Options': 'DENY', + 'Content-Security-Policy': "frame-ancestors 'none'", + 'Cross-Origin-Resource-Policy': 'same-origin', + ...headers + }; +} + +function isAllowedWebSocketOrigin(req) { + const origin = req.headers.origin; + if (!origin) return true; + const host = req.headers.host; + if (!host) return false; + return origin === 'http://' + host; +} + +// ========== HTTP Request Handler ========== + +function handleRequest(req, res) { + if (!isAuthorized(req)) { + res.writeHead(403, securityHeaders({ 'Content-Type': 'text/html; charset=utf-8' })); + res.end(FORBIDDEN_PAGE); + return; + } + touchActivity(); // only authorized requests count as activity + + // Mirror the key into a cookie so same-origin subresources (/files/*) can + // authenticate after bootstrap. HttpOnly keeps it away from page scripts; the + // WebSocket Origin check below is what blocks cross-origin localhost injection. + res.setHeader('Set-Cookie', + COOKIE_NAME + '=' + TOKEN + '; HttpOnly; SameSite=Strict; Path=/'); + + const pathname = pathnameOf(req.url); + const keyFromQuery = queryKey(req.url); + if (req.method === 'GET' && pathname === '/' && keyFromQuery && timingSafeEqualStr(keyFromQuery, TOKEN)) { + res.writeHead(200, securityHeaders({ 'Content-Type': 'text/html; charset=utf-8' })); + res.end(bootstrapPage(keyFromQuery)); + } else if (req.method === 'GET' && pathname === '/') { + const screenFile = getNewestScreen(); + let html = screenFile + ? (raw => isFullDocument(raw) ? raw : wrapInFrame(raw))(fs.readFileSync(screenFile, 'utf-8')) + : waitingPage(); + + if (html.includes('')) { + html = html.replace('', helperInjection + '\n'); + } else { + html += helperInjection; + } + + res.writeHead(200, securityHeaders({ 'Content-Type': 'text/html; charset=utf-8' })); + res.end(html); + } else if (req.method === 'GET' && pathname.startsWith('/files/')) { + const fileName = path.basename(pathname.slice(7)); + const filePath = path.join(CONTENT_DIR, fileName); + // Reject empty/dotfile names and anything that isn't a regular file — + // `/files/` would otherwise resolve to CONTENT_DIR and crash readFileSync (EISDIR). + if (!fileName || fileName.startsWith('.') || !isRegularFileInsideContentDir(filePath)) { + res.writeHead(404, securityHeaders()); + res.end('Not found'); + return; + } + const ext = path.extname(filePath).toLowerCase(); + const contentType = MIME_TYPES[ext] || 'application/octet-stream'; + res.writeHead(200, securityHeaders({ 'Content-Type': contentType })); + res.end(fs.readFileSync(filePath)); + } else { + res.writeHead(404, securityHeaders()); + res.end('Not found'); + } +} + +// ========== WebSocket Connection Handling ========== + +const clients = new Set(); + +function handleUpgrade(req, socket) { + if (!isAuthorized(req) || !isAllowedWebSocketOrigin(req)) { socket.destroy(); return; } + + const key = req.headers['sec-websocket-key']; + if (!key) { socket.destroy(); return; } + + const accept = computeAcceptKey(key); + socket.write( + 'HTTP/1.1 101 Switching Protocols\r\n' + + 'Upgrade: websocket\r\n' + + 'Connection: Upgrade\r\n' + + 'Sec-WebSocket-Accept: ' + accept + '\r\n\r\n' + ); + + let buffer = Buffer.alloc(0); + clients.add(socket); + + socket.on('data', (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + while (buffer.length > 0) { + let result; + try { + result = decodeFrame(buffer); + } catch (e) { + socket.end(encodeFrame(OPCODES.CLOSE, Buffer.alloc(0))); + clients.delete(socket); + return; + } + if (!result) break; + buffer = buffer.slice(result.bytesConsumed); + + switch (result.opcode) { + case OPCODES.TEXT: + handleMessage(result.payload.toString()); + break; + case OPCODES.CLOSE: + socket.end(encodeFrame(OPCODES.CLOSE, Buffer.alloc(0))); + clients.delete(socket); + return; + case OPCODES.PING: + socket.write(encodeFrame(OPCODES.PONG, result.payload)); + break; + case OPCODES.PONG: + break; + default: { + const closeBuf = Buffer.alloc(2); + closeBuf.writeUInt16BE(1003); + socket.end(encodeFrame(OPCODES.CLOSE, closeBuf)); + clients.delete(socket); + return; + } + } + } + }); + + socket.on('close', () => clients.delete(socket)); + socket.on('error', () => clients.delete(socket)); +} + +function handleMessage(text) { + let event; + try { + event = JSON.parse(text); + } catch (e) { + console.error('Failed to parse WebSocket message:', e.message); + return; + } + touchActivity(); + console.log(JSON.stringify({ source: 'user-event', ...event })); + if (event && event.choice) { + const eventsFile = path.join(STATE_DIR, 'events'); + fs.appendFileSync(eventsFile, JSON.stringify(event) + '\n'); + } +} + +function broadcast(msg) { + const frame = encodeFrame(OPCODES.TEXT, Buffer.from(JSON.stringify(msg))); + for (const socket of clients) { + try { socket.write(frame); } catch (e) { clients.delete(socket); } + } +} + +// Best-effort: open the user's browser the first time a screen is actually ready +// to show. Skips when disabled, on a non-loopback (remote) bind, or when a +// browser is already connected. Override the launcher with BRAINSTORM_OPEN_CMD. +let browserOpened = false; +function maybeOpenBrowser() { + if (browserOpened) return; + browserOpened = true; + if (!process.env.BRAINSTORM_OPEN) return; // opt-in: only after the user approves the companion + if (HOST !== '127.0.0.1' && HOST !== 'localhost') return; + if (clients.size > 0) return; // the user already opened it + const url = companionUrl(); // must carry the key or the gate 403s it + const cp = require('child_process'); + // Operator-provided launcher: run as given (this env var is trusted operator input). + if (process.env.BRAINSTORM_OPEN_CMD) { + try { cp.exec(process.env.BRAINSTORM_OPEN_CMD + ' ' + JSON.stringify(url), () => {}); } catch (e) { /* best effort */ } + return; + } + // Platform launchers: pass the URL as an argv element via execFile (no shell), + // so a url-host containing shell metacharacters can't inject a command. + const launcher = browserLauncherForPlatform(url); + if (!launcher) return; // headless: nothing to open + try { cp.execFile(launcher.bin, launcher.args, () => {}); } catch (e) { /* best effort */ } +} + +// ========== Activity Tracking ========== + +// Idle timeout: shut down after this long with no activity. Default 4 hours; +// override with BRAINSTORM_IDLE_TIMEOUT_MS (start-server.sh: --idle-timeout-minutes). +const IDLE_TIMEOUT_MS = (() => { + const ms = Number(process.env.BRAINSTORM_IDLE_TIMEOUT_MS); + return Number.isFinite(ms) && ms > 0 ? ms : 4 * 60 * 60 * 1000; +})(); +// How often the watchdog checks for owner-death / idleness. Configurable mainly +// so tests can run fast; production default is 60s. +const LIFECYCLE_CHECK_MS = (() => { + const ms = Number(process.env.BRAINSTORM_LIFECYCLE_CHECK_MS); + return Number.isFinite(ms) && ms > 0 ? ms : 60 * 1000; +})(); +let lastActivity = Date.now(); + +function touchActivity() { + lastActivity = Date.now(); +} + +// ========== File Watching ========== + +const debounceTimers = new Map(); + +// ========== Server Startup ========== + +function startServer() { + if (!fs.existsSync(CONTENT_DIR)) fs.mkdirSync(CONTENT_DIR, { recursive: true }); + if (!fs.existsSync(STATE_DIR)) fs.mkdirSync(STATE_DIR, { recursive: true }); + + // Track known files to distinguish new screens from updates. + // macOS fs.watch reports 'rename' for both new files and overwrites, + // so we can't rely on eventType alone. + const knownFiles = new Set( + fs.readdirSync(CONTENT_DIR).filter(f => !f.startsWith('.') && f.endsWith('.html')) + ); + + const server = http.createServer(handleRequest); + server.on('upgrade', handleUpgrade); + + const watcher = fs.watch(CONTENT_DIR, (eventType, filename) => { + if (!filename || filename.startsWith('.') || !filename.endsWith('.html')) return; + + if (debounceTimers.has(filename)) clearTimeout(debounceTimers.get(filename)); + debounceTimers.set(filename, setTimeout(() => { + debounceTimers.delete(filename); + const filePath = path.join(CONTENT_DIR, filename); + + if (!fs.existsSync(filePath)) return; // file was deleted + touchActivity(); + + if (!knownFiles.has(filename)) { + knownFiles.add(filename); + const eventsFile = path.join(STATE_DIR, 'events'); + if (fs.existsSync(eventsFile)) fs.unlinkSync(eventsFile); + console.log(JSON.stringify({ type: 'screen-added', file: filePath })); + maybeOpenBrowser(); + } else { + console.log(JSON.stringify({ type: 'screen-updated', file: filePath })); + } + + broadcast({ type: 'reload' }); + }, 100)); + }); + watcher.on('error', (err) => console.error('fs.watch error:', err.message)); + + function shutdown(reason) { + console.log(JSON.stringify({ type: 'server-stopped', reason })); + const infoFile = path.join(STATE_DIR, 'server-info'); + if (fs.existsSync(infoFile)) fs.unlinkSync(infoFile); + fs.writeFileSync( + path.join(STATE_DIR, 'server-stopped'), + JSON.stringify({ reason, timestamp: Date.now() }) + '\n' + ); + watcher.close(); + clearInterval(lifecycleCheck); + // Close any upgraded WebSocket sockets so server.close() can complete and + // the process actually exits instead of lingering on an open connection. + for (const socket of clients) { + try { socket.destroy(); } catch (e) { /* already gone */ } + } + server.close(() => process.exit(0)); + } + + function ownerAlive() { + if (!ownerPid) return true; + try { process.kill(ownerPid, 0); return true; } catch (e) { return e.code === 'EPERM'; } + } + + // Periodically exit if the owner process died or we've been idle too long. + const lifecycleCheck = setInterval(() => { + if (!ownerAlive()) shutdown('owner process exited'); + else if (Date.now() - lastActivity > IDLE_TIMEOUT_MS) shutdown('idle timeout'); + }, LIFECYCLE_CHECK_MS); + lifecycleCheck.unref(); + + // Validate owner PID at startup. If it's already dead, the PID resolution + // was wrong (common on WSL, Tailscale SSH, and cross-user scenarios). + // Disable monitoring and rely on the idle timeout instead. + if (ownerPid) { + try { process.kill(ownerPid, 0); } + catch (e) { + if (e.code !== 'EPERM') { + console.log(JSON.stringify({ type: 'owner-pid-invalid', pid: ownerPid, reason: 'dead at startup' })); + ownerPid = null; + } + } + } + + // If the preferred port is already taken (e.g. a previous server is still + // alive), fall back to a random port once instead of failing. + let triedFallback = false; + + function onListen() { + // Cookie name keys on the ACTUAL bound port (may differ from the preferred + // one after an EADDRINUSE fallback) so it can't collide with another server's + // cookie in the shared localhost jar. + COOKIE_NAME = 'brainstorm-key-' + PORT; + // Record the bound port AND token so the next restart of this session reuses + // them — but ONLY when we got our preferred port. On a fallback we bound a + // *different* port because someone else holds the preferred one; persisting + // would overwrite the shared files and strand that other session's open tab. + if (PORT_FILE && !triedFallback) { + try { fs.writeFileSync(PORT_FILE, String(PORT)); } catch (e) { /* best effort */ } + if (TOKEN_FILE) { + try { + fs.writeFileSync(TOKEN_FILE, TOKEN, { mode: 0o600 }); + chmodOwnerOnly(TOKEN_FILE); + } catch (e) { /* best effort */ } + } + } + const info = JSON.stringify({ + type: 'server-started', port: Number(PORT), host: HOST, + url_host: URL_HOST, url: companionUrl(), + screen_dir: CONTENT_DIR, state_dir: STATE_DIR, idle_timeout_ms: IDLE_TIMEOUT_MS + }); + console.log(info); + // server-info embeds the key — keep it owner-only. + fs.writeFileSync(path.join(STATE_DIR, 'server-info'), info + '\n', { mode: 0o600 }); + } + + server.on('error', (err) => { + if (err.code === 'EADDRINUSE' && !triedFallback) { + if (tokenSource === 'env') { + console.error('Server failed to bind: preferred port is in use and BRAINSTORM_TOKEN is set; refusing fallback with explicit token'); + process.exit(1); + } + triedFallback = true; + PORT = randomPort(); + if (tokenSource === 'file') { + TOKEN = generateToken(); + tokenSource = 'generated-fallback'; + } + server.listen(PORT, HOST, onListen); + } else { + console.error('Server failed to bind:', err.message); + process.exit(1); + } + }); + server.listen(PORT, HOST, onListen); +} + +if (require.main === module) { + startServer(); +} + +module.exports = { + computeAcceptKey, + encodeFrame, + decodeFrame, + browserLauncherForPlatform, + OPCODES, + MAX_FRAME_PAYLOAD_BYTES +}; diff --git a/plugins/codex/plugins/superpowers-zh/skills/brainstorming/scripts/start-server.sh b/plugins/codex/plugins/superpowers-zh/skills/brainstorming/scripts/start-server.sh new file mode 100755 index 00000000..016a8e48 --- /dev/null +++ b/plugins/codex/plugins/superpowers-zh/skills/brainstorming/scripts/start-server.sh @@ -0,0 +1,209 @@ +#!/usr/bin/env bash +# Start the brainstorm server and output connection info +# Usage: start-server.sh [--project-dir ] [--host ] [--url-host ] [--foreground] [--background] +# +# Starts server on a random high port, outputs JSON with URL. +# Each session gets its own directory to avoid conflicts. +# +# Options: +# --project-dir Store session files under /.superpowers/brainstorm/ +# instead of /tmp. Files persist after server stops. +# --host Host/interface to bind (default: 127.0.0.1). +# Use 0.0.0.0 in remote/containerized environments. +# --url-host Hostname shown in returned URL JSON. +# --idle-timeout-minutes Shut down after n minutes idle (default 240 = 4h). +# --open Auto-open the browser on the first screen (use only +# after the user approves the visual companion). +# --foreground Run server in the current terminal (no backgrounding). +# --background Force background mode (overrides Codex auto-foreground). + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +# Parse arguments +PROJECT_DIR="" +FOREGROUND="false" +FORCE_BACKGROUND="false" +BIND_HOST="127.0.0.1" +URL_HOST="" +IDLE_TIMEOUT_MINUTES="" +while [[ $# -gt 0 ]]; do + case "$1" in + --project-dir) + PROJECT_DIR="$2" + shift 2 + ;; + --host) + BIND_HOST="$2" + shift 2 + ;; + --url-host) + URL_HOST="$2" + shift 2 + ;; + --idle-timeout-minutes) + IDLE_TIMEOUT_MINUTES="$2" + shift 2 + ;; + --open) + export BRAINSTORM_OPEN=1 + shift + ;; + --foreground|--no-daemon) + FOREGROUND="true" + shift + ;; + --background|--daemon) + FORCE_BACKGROUND="true" + shift + ;; + *) + echo "{\"error\": \"Unknown argument: $1\"}" + exit 1 + ;; + esac +done + +if [[ -z "$URL_HOST" ]]; then + if [[ "$BIND_HOST" == "127.0.0.1" || "$BIND_HOST" == "localhost" ]]; then + URL_HOST="localhost" + else + URL_HOST="$BIND_HOST" + fi +fi + +if [[ -n "$IDLE_TIMEOUT_MINUTES" ]]; then + if ! [[ "$IDLE_TIMEOUT_MINUTES" =~ ^[0-9]+$ ]] || [[ "$IDLE_TIMEOUT_MINUTES" -lt 1 ]]; then + echo "{\"error\": \"--idle-timeout-minutes must be a positive integer\"}" + exit 1 + fi + export BRAINSTORM_IDLE_TIMEOUT_MS=$(( IDLE_TIMEOUT_MINUTES * 60 * 1000 )) +fi + +is_windows_like_shell() { + case "${OSTYPE:-}" in + msys*|cygwin*|mingw*) return 0 ;; + esac + if [[ -n "${MSYSTEM:-}" ]]; then + return 0 + fi + local uname_s + uname_s="$(uname -s 2>/dev/null || true)" + case "$uname_s" in + MSYS*|MINGW*|CYGWIN*) return 0 ;; + esac + return 1 +} + +# Some environments reap detached/background processes. Auto-foreground when detected. +if [[ -n "${CODEX_CI:-}" && "$FOREGROUND" != "true" && "$FORCE_BACKGROUND" != "true" ]]; then + FOREGROUND="true" +fi + +# Windows/Git Bash reaps nohup background processes. Auto-foreground when detected. +if [[ "$FOREGROUND" != "true" && "$FORCE_BACKGROUND" != "true" ]]; then + if is_windows_like_shell; then + FOREGROUND="true" + fi +fi + +# Session files (server.log, server-info, .last-token) embed the session key — +# keep everything this script and the server create owner-only. +umask 077 + +# Generate unique session directory +SESSION_ID="$$-$(date +%s)" + +if [[ -n "$PROJECT_DIR" ]]; then + SESSION_DIR="${PROJECT_DIR}/.superpowers/brainstorm/${SESSION_ID}" + # Persist the bound port and key per project so a restart reuses them and an + # already-open browser tab reconnects to the same URL with a valid cookie. + export BRAINSTORM_PORT_FILE="${PROJECT_DIR}/.superpowers/brainstorm/.last-port" + export BRAINSTORM_TOKEN_FILE="${PROJECT_DIR}/.superpowers/brainstorm/.last-token" +else + SESSION_DIR="/tmp/brainstorm-${SESSION_ID}" +fi + +STATE_DIR="${SESSION_DIR}/state" +PID_FILE="${STATE_DIR}/server.pid" +LOG_FILE="${STATE_DIR}/server.log" +SERVER_ID_FILE="${STATE_DIR}/server-instance-id" + +# Create fresh session directory with content and state peers +mkdir -p "${SESSION_DIR}/content" "$STATE_DIR" + +SERVER_ID="" +if [[ -r /dev/urandom ]]; then + SERVER_ID="$(od -An -N24 -tx1 /dev/urandom 2>/dev/null | tr -d ' \n' || true)" +fi +if ! [[ "$SERVER_ID" =~ ^[A-Za-z0-9_-]{32,64}$ ]]; then + SERVER_ID="$(printf '%08x%08x%08x%08x' "$$" "$(date +%s)" "${RANDOM:-0}" "${RANDOM:-0}")" +fi +printf '%s\n' "$SERVER_ID" > "$SERVER_ID_FILE" +chmod 600 "$SERVER_ID_FILE" 2>/dev/null || true + +# Kill any existing server +if [[ -f "$PID_FILE" ]]; then + old_pid=$(cat "$PID_FILE") + kill "$old_pid" 2>/dev/null + rm -f "$PID_FILE" +fi + +cd "$SCRIPT_DIR" || exit 1 + +# Resolve the harness PID (grandparent of this script). +# $PPID is the ephemeral shell the harness spawned to run us — it dies +# when this script exits. The harness itself is $PPID's parent. +OWNER_PID="$(ps -o ppid= -p "$PPID" 2>/dev/null | tr -d ' ')" +if [[ -z "$OWNER_PID" || "$OWNER_PID" == "1" ]]; then + OWNER_PID="$PPID" +fi + +# Windows/MSYS2: Node.js cannot see POSIX PIDs from the MSYS2 namespace. +# Passing a PID node cannot verify causes server to log owner-pid-invalid +# and self-terminate at the 60-second lifecycle check. Clear it so the +# watchdog is disabled and the idle timeout becomes the only shutdown trigger. +if is_windows_like_shell; then + OWNER_PID="" +fi + +# Foreground mode for environments that reap detached/background processes. +if [[ "$FOREGROUND" == "true" ]]; then + env BRAINSTORM_DIR="$SESSION_DIR" BRAINSTORM_HOST="$BIND_HOST" BRAINSTORM_URL_HOST="$URL_HOST" BRAINSTORM_OWNER_PID="$OWNER_PID" node server.cjs "--brainstorm-server-id=$SERVER_ID" & + SERVER_PID=$! + echo "$SERVER_PID" > "$PID_FILE" + wait "$SERVER_PID" + exit $? +fi + +# Start server, capturing output to log file +# Use nohup to survive shell exit; disown to remove from job table +nohup env BRAINSTORM_DIR="$SESSION_DIR" BRAINSTORM_HOST="$BIND_HOST" BRAINSTORM_URL_HOST="$URL_HOST" BRAINSTORM_OWNER_PID="$OWNER_PID" node server.cjs "--brainstorm-server-id=$SERVER_ID" > "$LOG_FILE" 2>&1 & +SERVER_PID=$! +disown "$SERVER_PID" 2>/dev/null +echo "$SERVER_PID" > "$PID_FILE" + +# Wait for server-started message (check log file) +for _ in {1..50}; do + if grep -q "server-started" "$LOG_FILE" 2>/dev/null; then + # Verify server is still alive after a short window (catches process reapers) + alive="true" + for _ in {1..20}; do + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + alive="false" + break + fi + sleep 0.1 + done + if [[ "$alive" != "true" ]]; then + echo "{\"error\": \"Server started but was killed. Retry in a persistent terminal with: $SCRIPT_DIR/start-server.sh${PROJECT_DIR:+ --project-dir $PROJECT_DIR} --host $BIND_HOST --url-host $URL_HOST --foreground\"}" + exit 1 + fi + grep "server-started" "$LOG_FILE" | head -1 + exit 0 + fi + sleep 0.1 +done + +# Timeout - server didn't start +echo '{"error": "Server failed to start within 5 seconds"}' +exit 1 diff --git a/plugins/codex/plugins/superpowers-zh/skills/brainstorming/scripts/stop-server.sh b/plugins/codex/plugins/superpowers-zh/skills/brainstorming/scripts/stop-server.sh new file mode 100755 index 00000000..7cacfe94 --- /dev/null +++ b/plugins/codex/plugins/superpowers-zh/skills/brainstorming/scripts/stop-server.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# Stop the brainstorm server and clean up +# Usage: stop-server.sh +# +# Kills the server process. Only deletes session directory if it's +# under /tmp (ephemeral). Persistent directories (.superpowers/) are +# kept so mockups can be reviewed later. + +SESSION_DIR="$1" + +if [[ -z "$SESSION_DIR" ]]; then + echo '{"error": "Usage: stop-server.sh "}' + exit 1 +fi + +STATE_DIR="${SESSION_DIR}/state" +PID_FILE="${STATE_DIR}/server.pid" +SERVER_ID_FILE="${STATE_DIR}/server-instance-id" + +mark_stopped() { + local reason="$1" + rm -f "${STATE_DIR}/server-info" + printf '{"reason":"%s","timestamp":%s}\n' "$reason" "$(date +%s)" > "${STATE_DIR}/server-stopped" +} + +read_expected_server_id() { + [[ -f "$SERVER_ID_FILE" ]] || return 1 + local id + id="$(tr -d '\r\n' < "$SERVER_ID_FILE" 2>/dev/null || true)" + [[ "$id" =~ ^[A-Za-z0-9_-]{32,64}$ ]] || return 1 + printf '%s\n' "$id" +} + +command_line_for_pid() { + local pid="$1" + if [[ -r "/proc/$pid/cmdline" ]]; then + tr '\0' '\n' < "/proc/$pid/cmdline" 2>/dev/null || true + return 0 + fi + ps -ww -p "$pid" -o command= 2>/dev/null || ps -f -p "$pid" 2>/dev/null | sed '1d' || true +} + +command_has_server_id() { + local pid="$1" + local expected="$2" + local expected_arg="--brainstorm-server-id=$expected" + if [[ -r "/proc/$pid/cmdline" ]]; then + local arg + while IFS= read -r -d '' arg || [[ -n "$arg" ]]; do + [[ "$arg" == "$expected_arg" ]] && return 0 + done < "/proc/$pid/cmdline" + return 1 + fi + local command_line + command_line="$(command_line_for_pid "$pid")" + [[ -n "$command_line" ]] || return 1 + case " $command_line " in + *" $expected_arg "*) return 0 ;; + *) return 1 ;; + esac +} + +# Confirm a PID has this session's per-start instance id, not just a familiar +# process name. Ambiguous or legacy metadata fails closed as stale_pid. +is_brainstorm_server() { + kill -0 "$1" 2>/dev/null || return 1 + local expected_id + expected_id="$(read_expected_server_id)" || return 1 + command_has_server_id "$1" "$expected_id" || return 1 + return 0 +} + +if [[ -f "$PID_FILE" ]]; then + pid=$(cat "$PID_FILE") + + # Refuse to signal a PID we can't prove is our server. A stale pid file may + # point at an unrelated process after a reboot/PID wraparound. + if ! is_brainstorm_server "$pid"; then + rm -f "$PID_FILE" "$SERVER_ID_FILE" + mark_stopped "stale_pid" + echo '{"status": "stale_pid"}' + exit 0 + fi + + # Try to stop gracefully, fallback to force if still alive + kill "$pid" 2>/dev/null || true + + # Wait for graceful shutdown (up to ~2s) + for _ in {1..20}; do + if ! kill -0 "$pid" 2>/dev/null; then + break + fi + sleep 0.1 + done + + # If still running, escalate to SIGKILL + if kill -0 "$pid" 2>/dev/null; then + kill -9 "$pid" 2>/dev/null || true + + # Give SIGKILL a moment to take effect + sleep 0.1 + fi + + if kill -0 "$pid" 2>/dev/null; then + echo '{"status": "failed", "error": "process still running"}' + exit 1 + fi + + rm -f "$PID_FILE" "$SERVER_ID_FILE" "${STATE_DIR}/server.log" + mark_stopped "stop-server.sh" + + # Only delete ephemeral /tmp directories + if [[ "$SESSION_DIR" == /tmp/* ]]; then + rm -rf "$SESSION_DIR" + fi + + echo '{"status": "stopped"}' +else + echo '{"status": "not_running"}' +fi diff --git a/plugins/codex/plugins/superpowers-zh/skills/brainstorming/visual-companion.md b/plugins/codex/plugins/superpowers-zh/skills/brainstorming/visual-companion.md new file mode 100644 index 00000000..dbc7ee82 --- /dev/null +++ b/plugins/codex/plugins/superpowers-zh/skills/brainstorming/visual-companion.md @@ -0,0 +1,288 @@ +# 可视化伴侣指南 + +基于浏览器的可视化头脑风暴伴侣,用于展示模型稿、图示和选项。 + +## 何时使用 + +针对每个问题而非每个会话进行决定。判断标准是:**用户通过看它而不是阅读它,是否能理解得更好?** + +当内容本身是可视化内容时,**使用浏览器**: + +- **UI 模型稿**——线框图、布局、导航结构、组件设计 +- **架构图**——系统组件、数据流、关系图 +- **并排视觉比较**——比较两种布局、两套配色方案、两个设计方向 +- **设计润色**——当问题涉及观感、间距、视觉层级时 +- **空间关系**——以图示形式呈现的状态机、流程图、实体关系 + +当内容是文本或表格时,**使用终端**: + +- **需求和范围问题**——“X 是什么意思?”、“哪些功能在范围内?” +- **概念性 A/B/C 选择**——在用文字描述的方案之间进行选择 +- **权衡清单**——优缺点、比较表 +- **技术决策**——API 设计、数据建模、架构方案选择 +- **澄清问题**——任何答案是文字而非视觉偏好的问题 + +一个*关于* UI 主题的问题并不自动等同于视觉问题。“你想要哪种向导?”是概念性问题——使用终端。“这些向导布局中,哪一种感觉合适?”是视觉问题——使用浏览器。 + +## 工作原理 + +服务器会监视一个目录中的 HTML 文件,并将最新的文件提供给浏览器。你将 HTML 内容写入 `screen_dir`,用户会在其浏览器中看到内容,并可通过点击选择选项。选择结果会记录到 `state_dir/events`,供你在下一轮读取。 + +**内容片段与完整文档:**如果你的 HTML 文件以 `/.superpowers/brainstorm/` 中查找会话目录。 + +**注意:**将项目根目录作为 `--project-dir` 传入,以便模型稿持久保存在 `.superpowers/brainstorm/` 中,并在服务器重启后继续保留。如果不这样做,文件会写入 `/tmp` 并被清理。如果 `.superpowers/` 尚未加入 `.gitignore`,请提醒用户添加它。 + +**按平台启动服务器:** + +**Claude Code:** +```bash +# 默认模式即可——脚本会自行将服务器置于后台运行。 +bash scripts/start-server.sh --project-dir /path/to/project --open +``` + +在 Windows 上,脚本会自动检测并切换到前台模式(这会阻塞工具调用)。在 Bash 工具调用中使用 `run_in_background: true`,使服务器能够跨对话轮次持续运行,然后在下一轮读取 `$STATE_DIR/server-info` 以获取 URL 和端口。 + +**Codex:** +```bash +# Codex 会清理后台进程。脚本会自动检测 CODEX_CI 并 +# 切换到前台模式。正常运行即可——无需额外标志。 +bash scripts/start-server.sh --project-dir /path/to/project --open +``` + +**Gemini CLI:** +```bash +# 使用 --foreground,并在 shell 工具调用中设置 is_background: true, +# 使进程能够跨轮次持续运行 +bash scripts/start-server.sh --project-dir /path/to/project --open --foreground +``` + +**Copilot CLI:** +```bash +# 使用 Copilot CLI 的非阻塞/后台 shell 机制启动,使服务器跨轮次持续运行。 +# 保留 --foreground,让运行平台而不是脚本负责后台化。启动脚本是 .sh, +# 因此通过 bash 调用(Windows 上从 PowerShell 工具调用 Git Bash 的 bash.exe)。 +bash scripts/start-server.sh --project-dir /path/to/project --open --foreground +``` + +**其他环境:**服务器必须在后台跨对话轮次持续运行。如果你的环境会清理已分离的进程,请使用 `--foreground`,并通过你所在平台的后台执行机制启动该命令。 + +如果你的浏览器无法访问该 URL(这在远程/容器化环境中很常见),请绑定非环回主机: + +```bash +bash scripts/start-server.sh \ + --project-dir /path/to/project \ + --host 0.0.0.0 \ + --url-host localhost +``` + +使用 `--url-host` 控制返回的 URL JSON 中输出的主机名。 + +## 循环 +1. **检查服务器是否仍在运行**,然后**将 HTML 写入** `screen_dir` 中的新文件: + - **必需:在提及 URL 或推送界面之前,确认服务器仍在运行。** 检查 `$STATE_DIR/server-info` 是否存在,并且 `$STATE_DIR/server-stopped` 不存在。如果服务器已关闭,请使用**相同的 `--project-dir`** 通过 `start-server.sh` 重新启动它——它会复用相同的端口,因此用户已打开的标签页会自行重新连接(服务器停机期间会显示“已暂停”遮罩层),你无需发送新的 URL。服务器空闲 4 小时后会自动退出(可通过 `--idle-timeout-minutes` 配置)。 + - 使用语义化文件名:`platform.html`、`visual-style.html`、`layout.html` + - **绝不要重复使用文件名**——每个界面都使用一个全新的文件 + - 使用你的文件创建工具——**绝不要使用 cat/heredoc**(会向终端倾倒大量杂乱信息) + - 服务器会自动提供最新的文件 + +2. **告诉用户会看到什么,然后结束你的回合:** + - 每一步都提醒他们 URL(而不只是第一步) + - 简要概括界面上显示的内容(例如,“正在展示主页的 3 种布局选项”) + - 请他们在终端中回复:“看一下,然后告诉我你的想法。如果愿意,请点击选择一个选项。” + +3. **在你的下一个回合中**——用户在终端中回复后: + - 如果 `$STATE_DIR/events` 存在,则读取它——其中以 JSON 行的形式包含用户的浏览器交互 + - 将其与用户的终端文本合并,以了解完整情况 + - 终端消息是主要反馈;`state_dir/events` 提供结构化交互数据 + +4. **迭代或推进**——如果反馈会改变当前界面,则写入一个新文件(例如 `layout-v2.html`)。只有当前步骤得到验证后,才能进入下一个问题。 + +5. **返回终端时卸载内容**——当下一步不需要浏览器时(例如,提出澄清问题、讨论权衡取舍),推送一个等待界面以清除过时内容: + + ```html + +
+

继续在终端中...

+
+ ``` + + 这样可避免在对话已经继续推进后,用户仍盯着一个已经解决的选择。当下一个可视化问题出现时,像往常一样推送一个新的内容文件。 + +6. 重复上述步骤,直到完成。 + +## 编写内容片段 + +只编写放入页面内部的内容。服务器会自动使用框架模板将其包装起来(页眉、主题 CSS、连接状态以及所有交互基础设施)。 + +**最小示例:** + +```html +

哪种布局更合适?

+

请考虑可读性和视觉层级

+ +
+
+
A
+
+

单栏

+

简洁、专注的阅读体验

+
+
+
+
B
+
+

双栏

+

侧边栏导航搭配主内容区

+
+
+
+``` + +就这些。不需要 ``、CSS,也不需要 `