diff --git a/docs/exec-plans/completed/skills-mcp-tooling-fix.md b/docs/exec-plans/completed/skills-mcp-tooling-fix.md new file mode 100644 index 0000000..5e193ff --- /dev/null +++ b/docs/exec-plans/completed/skills-mcp-tooling-fix.md @@ -0,0 +1,103 @@ +# Skills / MCP / Completion 系统修复 + +**状态:** 已完成 +**日期:** 2026-03-10 +**关联调研:** `docs/research/skills-agent-sdk-review-2026-03-10.md` + +## 问题概述 + +三个子系统存在多处 bug,部分为 P0(功能不可用)级别: + +### 1. Skills 系统:Agent Skills 与 Slash Commands 混淆 + +**根因:** `CommandBadge.isSkill: boolean` 只区分"内置 vs 非内置",无法区分 agent_skill / slash_command / sdk_command / codepilot_command 四种执行语义。 + +**症状:** +- Agent Skill 的 SKILL.md 被展开为用户消息发送,绕过 SDK 原生加载 +- 用户补充上下文后 `/command` 本体丢失 +- Popover 中无法区分不同类型的命令 + +### 2. MCP 管理:远程服务器创建失败 + 双文件读写不一致 + +**根因:** +- POST 校验要求 `command` 字段,但 SSE/HTTP 服务器用 `url` 字段 +- GET 合并读取两个配置文件但 PUT 只写 settings.json +- DELETE 只检查 settings.json + +### 3. Completion 处理:前端+后端双重执行 + 硬编码 localhost + +**根因:** +- ChatView.tsx 和 route.ts 都处理 onboarding/checkin completion +- 后端通过 `fetch("http://localhost:${PORT}")` 回调自身 API + +## 修改清单 + +### Phase 1: SkillKind 类型系统 + +| 文件 | 改动 | +|------|------| +| `src/types/index.ts` | 新增 `SkillKind` 类型;`MCPServerConfig.command` 改为可选 | +| `src/app/api/skills/route.ts` | `SkillFile` 增加 `kind` 字段,各扫描函数返回正确 kind | +| `src/app/api/skills/[name]/route.ts` | GET/PUT 响应包含 `kind` | + +### Phase 2: MessageInput 核心修复 + +| 文件 | 改动 | +|------|------| +| `src/components/chat/MessageInput.tsx` | `isSkill` → `kind: SkillKind`;`handleSubmit` 按 kind 分流;Popover 分三组 | + +分流逻辑: +- `agent_skill`: 发送 `Use the {name} skill. User context: {text}`,displayOverride 显示 `/name` +- `slash_command` / `sdk_command`: 发送 `/{command} {text}` 原样给 SDK +- `codepilot_command`: 展开 COMMAND_PROMPTS,displayOverride 显示 `/command` + +### Phase 3: displayOverride 持久化链路 + +| 文件 | 改动 | +|------|------| +| `src/app/chat/page.tsx` | `sendFirstMessage` 接受 displayOverride 参数 | +| `src/app/api/chat/route.ts` | 提取 displayOverride,DB 存储用 displayOverride | +| `src/components/chat/ChatView.tsx` | 传递 displayOverride 到 startStream | +| `src/lib/stream-session-manager.ts` | StartStreamParams 增加 displayOverride | + +### Phase 4: MCP 双文件管理 + +| 文件 | 改动 | +|------|------| +| `src/app/api/plugins/mcp/route.ts` | POST 支持 SSE/HTTP;PUT 按 `_source` 分流写入;POST 检查双文件同名 | +| `src/app/api/plugins/mcp/[name]/route.ts` | DELETE 检查双文件 | +| `src/components/plugins/McpManager.tsx` | `MCPServerWithSource` 类型;保留 `_source` 标记 | +| `src/components/plugins/McpServerEditor.tsx` | 远程服务器不生成空 command | + +### Phase 5: SDK Init 元数据转发 + +| 文件 | 改动 | +|------|------| +| `src/lib/claude-client.ts` | system:init 转发 slash_commands/skills | +| `src/hooks/useSSEStream.ts` | 新增 `onInitMeta` 回调 | +| `src/lib/provider-resolver.ts` | `settingSources` 始终包含 `'user'` | + +### Phase 6: Completion 去重 + 去 localhost + +| 文件 | 改动 | +|------|------| +| `src/lib/onboarding-processor.ts` | **新增** — 从 onboarding route 提取核心逻辑 | +| `src/lib/checkin-processor.ts` | **新增** — 从 checkin route 提取核心逻辑 | +| `src/app/api/workspace/onboarding/route.ts` | 委托给 processOnboarding() | +| `src/app/api/workspace/checkin/route.ts` | 委托给 processCheckin() | +| `src/app/api/chat/route.ts` | processCompletionServerSide 直接调用处理函数,不走 HTTP | +| `src/components/chat/ChatView.tsx` | 删除 detectAssistantCompletion — 仅保留服务端路径 | + +## 架构决策 + +1. **Completion 单路径**:仅在服务端 `collectStreamResponse` 的 `finally` 块中处理,前端不再参与。理由:服务端路径在所有场景下都会执行(包括页面刷新、解析失败),前端路径是多余的且引入竞态。 + +2. **处理函数直接导入**:`processOnboarding/processCheckin` 作为 lib 函数导出,route handler 和 completion detector 都直接调用,消除 HTTP 回调。 + +3. **幂等保护**:`processOnboarding` 检查 `state.onboardingComplete`,`processCheckin` 检查 `state.lastCheckInDate === today`,即使意外多次调用也不会产生副作用。 + +## 测试 + +- 289 个单元测试全部通过 +- TypeScript 编译无错误 +- CDP 验证:Popover 三组分类正确,MCP 远程服务器创建/删除正常,Console 无 error diff --git a/docs/research/skills-agent-sdk-review-2026-03-10.md b/docs/research/skills-agent-sdk-review-2026-03-10.md new file mode 100644 index 0000000..400983e --- /dev/null +++ b/docs/research/skills-agent-sdk-review-2026-03-10.md @@ -0,0 +1,334 @@ +# Skills 调用审查报告(项目 vs Claude Agent SDK) + +日期:2026-03-10 + +本报告已对照 Anthropic 官方文档复核: + +- [Agent Skills](https://platform.claude.com/docs/en/agent-sdk/skills) +- [Slash Commands](https://platform.claude.com/docs/en/agent-sdk/slash-commands) + +## 结论 + +当前项目把两套本来不同的官方机制混成了一个“Skills”功能: + +1. Agent Skills:官方文档定义为 `.claude/skills/*/SKILL.md` 这类 filesystem-based skills,由 SDK/Claude Code 加载后,Claude 在相关时机自主决定是否使用。 +2. Slash Commands:官方文档定义为 `.claude/commands/*.md` 这类 `/command` 命令,用户通过显式输入 `/command` 调用。 + +而项目当前的主问题是: + +1. 前端把 Agent Skills、filesystem slash commands、SDK slash commands 混成一个列表和一套 badge 逻辑。 +2. 提交时没有区分官方原生路径,而是统一走“读取 markdown -> 展开正文 -> 作为普通用户消息发送”。 +3. SDK 返回的 slash commands 在补充上下文时还可能直接丢失 `/command` 本体。 + +这三点可以直接解释你观察到的现象: + +- 同样模型、同样 Skills,效果不如 Claude Code。 +- 聊天窗口里没有“技能调用”UI,而是直接出现 `SKILL.md` 文案。 + +## 主要发现 + +### [P0] Agent Skills 和 Slash Commands 被混成了一套 UI / 数据模型 + +证据: + +- [src/app/api/skills/route.ts](/Users/op7418/Documents/code/opus-4.6-test/src/app/api/skills/route.ts#L320) +- [src/components/chat/MessageInput.tsx](/Users/op7418/Documents/code/opus-4.6-test/src/components/chat/MessageInput.tsx#L483) +- [src/components/chat/MessageInput.tsx](/Users/op7418/Documents/code/opus-4.6-test/src/components/chat/MessageInput.tsx#L551) + +当前实现: + +- `/api/skills` 同时扫描: + - `.claude/commands` + - `.claude/skills` + - `~/.claude/skills` + - `~/.agents/skills` + - 再合并 SDK 的 `supportedCommands()` +- MessageInput 把这些项目统一当成一种“skill badge”来处理。 + +但官方文档里,这至少是两套不同机制: + +- Agent Skills:来自 `.claude/skills`,由 Claude 自主调用。 +- Slash Commands:来自 `.claude/commands`,由用户显式发送 `/command` 调用。 + +影响: + +- UI 展示的“Skills”本身就不是官方语义。 +- 后续提交、渲染、状态展示都无法做正确分流。 +- 项目从概念层已经和 Claude Code 本体偏离。 + +### [P0] `.claude/skills/*/SKILL.md` 没有走官方 Agent Skills 路径,而是被前端展开成普通 prompt 文本 + +证据: + +- [src/components/chat/MessageInput.tsx](/Users/op7418/Documents/code/opus-4.6-test/src/components/chat/MessageInput.tsx#L685) +- [src/components/chat/MessageInput.tsx](/Users/op7418/Documents/code/opus-4.6-test/src/components/chat/MessageInput.tsx#L711) +- [src/lib/provider-resolver.ts](/Users/op7418/Documents/code/opus-4.6-test/src/lib/provider-resolver.ts#L488) + +当前实现: + +- 选中 `.claude/skills/*/SKILL.md` 项目后,前端请求 `/api/skills/:name` 读取 `SKILL.md` 全文。 +- 然后把它拼成: + - `expandedPrompt` + - 或 `expandedPrompt + "\n\nUser context: ..."` +- 最后直接作为 `content` 发给 `/api/chat`。 + +但官方 Skills 文档描述的是另一条路径: + +- Skills 是 filesystem-based。 +- 通过 `settingSources` 从 `~/.claude/skills` / `project/.claude/skills` 加载。 +- Claude 会在相关时机自主决定是否使用 Skill。 +- SDK 没有和 subagent 对等的“程序化注册 Skills API”。 + +所以 `.claude/skills` 这条线,不应该在前端被展开成一段普通用户消息;那已经不是 SDK-native Agent Skill 调用了。 + +影响: + +- Claude 对 Agent Skill 的原生选择和上下文组织被绕开。 +- `SKILL.md` 的说明文本退化成普通用户输入,提示词污染更重。 +- 当前实现更像“把技能文档贴给模型”,而不是“让 Claude 使用 skill”。 + +### [P0] Slash Command 可以在 UI 里看到,但提交时经常不会真正执行 + +证据: + +- [src/app/api/skills/route.ts](/Users/op7418/Documents/code/opus-4.6-test/src/app/api/skills/route.ts#L323) +- [src/components/chat/MessageInput.tsx](/Users/op7418/Documents/code/opus-4.6-test/src/components/chat/MessageInput.tsx#L551) +- [src/components/chat/MessageInput.tsx](/Users/op7418/Documents/code/opus-4.6-test/src/components/chat/MessageInput.tsx#L689) +- [src/components/chat/MessageInput.tsx](/Users/op7418/Documents/code/opus-4.6-test/src/components/chat/MessageInput.tsx#L711) +- [src/app/api/skills/[name]/route.ts](/Users/op7418/Documents/code/opus-4.6-test/src/app/api/skills/%5Bname%5D/route.ts#L154) +- [src/app/api/skills/[name]/route.ts](/Users/op7418/Documents/code/opus-4.6-test/src/app/api/skills/%5Bname%5D/route.ts#L267) + +当前实现: + +- `/api/skills` 会把 SDK 的 `supportedCommands()` 结果合并进列表。 +- 但选中后,MessageInput 统一走“拉取 skill 文件内容”的路径。 +- `/api/skills/[name]` 实际只认识 project/global/installed skill 文件,不认识 SDK command。 + +所以当用户选中一个 SDK command: + +- 如果用户还补了上下文,`expandedPrompt` 取不到内容,最终发送的是 `"\n\nUser context: xxx"`。 +- 真正的 `/command` 本体丢了。 +- 如果没有补上下文,才会退化成发 `badge.command`。 + +这属于执行级 bug,不只是体验问题。 + +### [P1] 聊天 UI 没有“技能调用”状态,是因为显示层拿到的就是展开后的正文 + +证据: + +- [src/components/chat/ChatView.tsx](/Users/op7418/Documents/code/opus-4.6-test/src/components/chat/ChatView.tsx#L532) +- [src/components/chat/MessageInput.tsx](/Users/op7418/Documents/code/opus-4.6-test/src/components/chat/MessageInput.tsx#L718) + +`ChatView.sendMessage()` 会把传入的 `content` 直接 optimistic render 成用户消息。 +而 skill / command 提交时传进去的就是展开后的 `finalPrompt`,不是“已选 Agent Skill”或“/command + 参数”的结构化调用信息。 + +因此前端不可能渲染出“技能胶囊 / 技能调用卡片 / slash command 状态”,因为从数据层开始,这次调用已经被降级成一段普通文本了。 + +### [P1] 前端的技能来源和 SDK 当前会话的真实可用项并不一致 + +证据: + +- [src/lib/agent-sdk-capabilities.ts](/Users/op7418/Documents/code/opus-4.6-test/src/lib/agent-sdk-capabilities.ts#L100) +- [src/lib/claude-client.ts](/Users/op7418/Documents/code/opus-4.6-test/src/lib/claude-client.ts#L797) +- [src/types/index.ts](/Users/op7418/Documents/code/opus-4.6-test/src/types/index.ts#L389) +- [node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts](/Users/op7418/Documents/code/opus-4.6-test/node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts#L1798) + +SDK 在 `system:init` 和 `supportedCommands()` 里都能返回当前会话的 commands / skills 信息。 +但项目目前的处理是: + +- `claude-client.ts` 收到 `system:init` 后,只转发了 `session_id/model/tools`。 +- SSE 类型里也没有承载 init skills/commands 的事件。 +- UI 主要依赖手工扫描文件系统,再补一份 capability cache。 + +这意味着: + +- UI 展示的列表不一定等于当前 SDK session 真实可用的 commands / skills。 +- 插件、settings source、provider scope、会话初始化状态变化,前端都可能感知不全。 + +### [P1] `~/.claude/skills` 的显示结果,和 SDK 实际加载结果很可能不一致 + +证据: + +- [src/app/api/skills/route.ts](/Users/op7418/Documents/code/opus-4.6-test/src/app/api/skills/route.ts#L320) +- [src/lib/provider-resolver.ts](/Users/op7418/Documents/code/opus-4.6-test/src/lib/provider-resolver.ts#L488) + +当前前端列表会直接扫描: + +- `~/.claude/commands` +- `project/.claude/commands` +- `project/.claude/skills` +- `~/.agents/skills` +- `~/.claude/skills` + +但官方 Skills 文档明确要求:要从 filesystem 加载 skills,需要把 `settingSources` 设成包含 `user` / `project`。 +而项目当前在 provider 有凭证时会把 `settingSources` 设成 `['project', 'local']`,跳过 `'user'` source。 + +基于官方文档,这里可以做出较强推断: + +- UI 扫描到了 `~/.claude/skills`,不代表 SDK session 真的加载到了这些 Agent Skills。 +- 当前 provider 配置下,user-level Agent Skills 很可能根本没有进入 Claude 的真实技能上下文。 + +另外,官方文档里我没有看到 `~/.agents/skills` 这个路径;这说明它至少不是 Anthropic 文档里的标准 Skills 路径。 + +### [P2] 项目里有 Agent SDK 原生 agent/skill preload 入口,但聊天主链路没有用到 + +证据: + +- [node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts](/Users/op7418/Documents/code/opus-4.6-test/node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts#L33) +- [src/lib/agent-sdk-agents.ts](/Users/op7418/Documents/code/opus-4.6-test/src/lib/agent-sdk-agents.ts#L1) +- [src/lib/claude-client.ts](/Users/op7418/Documents/code/opus-4.6-test/src/lib/claude-client.ts#L414) + +SDK 的 `AgentDefinition` 原生支持: + +- `prompt` +- `tools` +- `disallowedTools` +- `skills?: string[]` + +项目里也有 `agent-sdk-agents.ts` registry,但只是“未来集成”的壳,聊天主链路没有把这套能力真正接起来。 + +这不是当前 UI 泄漏的直接原因,但说明项目现在并没有充分使用 SDK-native agent/skill orchestration。 + +## 项目当前实现 vs Agent SDK 原生语义 + +| 维度 | 当前项目 | Claude Agent SDK / Claude Code | +|---|---|---| +| Agent Skills (`.claude/skills`) | 扫描后把 `SKILL.md` 展开成用户 prompt | SDK 从 filesystem 加载,Claude 自主决定是否使用 | +| Slash Commands (`.claude/commands`) | 也混在同一列表里,且可能被展开或丢失 `/command` | 用户显式发送 `/command ...`,Claude Code 解析执行 | +| UI 数据模型 | 两套机制混成一个“Skills” | Skills 和 Commands 是不同概念 | +| 列表来源 | 手工扫文件 + 缓存 commands | SDK session 自身可报告当前支持的 commands / skills | +| `~/.claude/skills` 加载 | UI 扫到就显示 | 需要 `settingSources` 包含 `user` | +| `~/.agents/skills` | 当成 installed skills 暴露给 UI | 官方 Skills 文档未见该标准路径 | + +## 为什么现在效果会比 Claude Code 差 + +核心不是模型差,而是调用协议变了。 + +一旦把 Agent Skills 和 Slash Commands 都降级成“展开 markdown 后发普通用户消息”,至少会丢掉这些东西: + +- Agent Skill 的原生加载和自主调用语义。 +- Slash Command 的显式命令语义。 +- `SKILL.md` / `/command` 和用户补充上下文之间的边界。 +- 会话级真实 command inventory。 +- 前端对“这是一次技能/命令调用”的识别能力。 +- Agent Skills 和 Slash Commands 的区分。 + +结果就是: + +- 模型读到更多噪声文本。 +- 同一份技能/命令在 Claude Code 里表现更稳定,在这里更像“临时拼 prompt”。 +- UI 和真实执行状态脱节。 + +## 建议的修复顺序 + +### 1. 先把 Agent Skills 和 Slash Commands 拆成两条执行链路 + +目标: + +- `.claude/skills` 走 Agent Skills 语义。 +- `.claude/commands` / SDK commands 走 Slash Commands 语义。 +- 不再共用“选中条目 -> 拉详情 -> 展开 markdown -> 发送”的逻辑。 + +### 2. 不要再在前端展开 `SKILL.md` + +目标: + +- 对于 Agent Skills,不要读取 `SKILL.md` 全文再拼成用户消息。 +- 应优先依赖 SDK-native filesystem loading + official skills path。 + +不要再把 `data.skill.content` 塞进 `content`。 + +### 3. Slash Command 必须原样提交 `/command` + +建议把当前 badge/item 拆成至少四类: + +- `builtin_prompt_command` +- `agent_skill` +- `filesystem_slash_command` +- `sdk_slash_command` + +其中 slash command 的提交目标应该是: + +- `/review` +- `/review src/foo.ts` +- `/foo-bar 用户补充参数` + +而不是 command markdown 正文。 + +### 4. 调用的显示和真实 prompt 分离 + +利用已有的 `displayOverride` 或新增结构化 message metadata: + +- UI 显示:`/command`、用户参数、或“已选择 Agent Skill” +- 实际发送:对应的原生调用形式 + +不要把展开后的系统说明显示到聊天气泡里。 + +### 5. 让 SDK session 成为可用 commands / skills 的真源 + +更合理的来源优先级: + +1. 当前活跃 SDK session 的 `supportedCommands()` / init metadata +2. 会话未建立前,再考虑本地扫描做 fallback + +不要让“手工扫描文件系统”长期作为主真源。 + +### 6. 给 `system:init` 增加专门事件,把 skills / commands 下发给前端 + +当前 `system:init` 被截断得太狠。建议至少把这些字段透出: + +- `slash_commands` +- `skills` +- `plugins` +- `output_style` + +这样 UI 才能知道当前 session 真正有哪些条目可用。 + +### 7. 修正 `settingSources`,保证 user-level Agent Skills 能真正进入 SDK session + +至少在需要加载 `~/.claude/skills` 时,要和官方 Skills 文档对齐: + +- `settingSources` 需要包含 `user` +- 否则 UI 看得到,Claude 实际用不到 + +### 8. 如果后续要做 SDK-native agent,别再继续用“系统 prompt 注入”模拟 + +现在 `agent-sdk-agents.ts` 已经有骨架。 +如果产品目标是接近 Claude Code 本体,后续应该优先使用: + +- `agent` +- `agents` +- `AgentDefinition.skills` + +而不是继续叠更多 markdown prompt 片段。 + +## 建议 ClaudeCode 直接改的点 + +建议先按下面顺序改: + +1. 改 [src/components/chat/MessageInput.tsx](/Users/op7418/Documents/code/opus-4.6-test/src/components/chat/MessageInput.tsx) + - 先区分 `agent_skill`、`filesystem_slash_command`、`sdk_slash_command`。 + - slash command 选择后,提交 `/command` 本体。 + - Agent Skill 不再请求详情并展开正文。 +2. 改 [src/app/api/skills/route.ts](/Users/op7418/Documents/code/opus-4.6-test/src/app/api/skills/route.ts) + - 列表返回明确的 `kind/source`,区分 Agent Skill、Slash Command、SDK command。 +3. 改 [src/app/api/skills/[name]/route.ts](/Users/op7418/Documents/code/opus-4.6-test/src/app/api/skills/%5Bname%5D/route.ts) + - 只为“真的需要查看文件正文”的条目提供详情;SDK command 和 Agent Skill 都不应依赖这里展开执行。 +4. 改 [src/lib/claude-client.ts](/Users/op7418/Documents/code/opus-4.6-test/src/lib/claude-client.ts) + - 转发 `system:init` 的 commands/skills 元数据。 +5. 改 [src/lib/provider-resolver.ts](/Users/op7418/Documents/code/opus-4.6-test/src/lib/provider-resolver.ts) + - 结合官方 Skills 文档重新审视 `settingSources`,不要把 user-level Agent Skills 扫出来但又不加载到 SDK session。 +6. 改 [src/types/index.ts](/Users/op7418/Documents/code/opus-4.6-test/src/types/index.ts) 和 [src/hooks/useSSEStream.ts](/Users/op7418/Documents/code/opus-4.6-test/src/hooks/useSSEStream.ts) + - 增加对应 SSE event/type。 +7. 补测试 + - “选中 Agent Skill”不再把 `SKILL.md` 文本显示进聊天窗口。 + - “选中 filesystem slash command + 输入上下文”实际发送的仍然是 `/command ...`。 + - “选中 SDK command + 输入上下文”实际发送的仍然是 `/command ...`。 + - `/api/skills` 返回 SDK command 时,不会再被 `/api/skills/[name]` 错误处理。 + +## 最终判断 + +这不是“Claude Code 比你们模型调得更好”这么简单。 +更准确地说,是你们现在把 Anthropic 官方文档里两套不同的机制混在一起,并统一降级成了 markdown prompt 注入。 + +只要这一层不改,同模型同技能/命令的效果很难对齐 Claude Code 本体。 diff --git a/src/__tests__/unit/onboarding-completion.test.ts b/src/__tests__/unit/onboarding-completion.test.ts new file mode 100644 index 0000000..c28eec2 --- /dev/null +++ b/src/__tests__/unit/onboarding-completion.test.ts @@ -0,0 +1,292 @@ +/** + * Unit tests for onboarding completion extraction and robust JSON parsing. + * + * Run with: npx tsx --test src/__tests__/unit/onboarding-completion.test.ts + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + extractCompletionFence, + parseCompletionPayload, + extractCompletion, +} from '../../lib/onboarding-completion'; + +// ─── Fence Extraction ──────────────────────────────────────────── + +describe('extractCompletionFence', () => { + it('should extract onboarding-complete fence with LF', () => { + const content = 'Some text\n```onboarding-complete\n{"q1":"hello"}\n```\nMore text'; + const result = extractCompletionFence(content); + assert.ok(result); + assert.equal(result!.type, 'onboarding'); + assert.equal(result!.rawPayload, '{"q1":"hello"}'); + }); + + it('should extract fence with CRLF', () => { + const content = '```onboarding-complete\r\n{"q1":"hello"}\r\n```'; + const result = extractCompletionFence(content); + assert.ok(result); + assert.equal(result!.rawPayload, '{"q1":"hello"}'); + }); + + it('should extract fence with trailing spaces after tag', () => { + const content = '```onboarding-complete \n{"q1":"hello"}\n```'; + const result = extractCompletionFence(content); + assert.ok(result); + }); + + it('should extract fence with leading whitespace before closing fence', () => { + const content = '```onboarding-complete\n{"q1":"hello"}\n ```'; + const result = extractCompletionFence(content); + assert.ok(result); + }); + + it('should extract fence with triple+ backticks', () => { + const content = '````onboarding-complete\n{"q1":"hello"}\n````'; + const result = extractCompletionFence(content); + assert.ok(result); + }); + + it('should extract checkin-complete fence', () => { + const content = '```checkin-complete\n{"q1":"did coding"}\n```'; + const result = extractCompletionFence(content); + assert.ok(result); + assert.equal(result!.type, 'checkin'); + }); + + it('should return null when no fence present', () => { + const content = 'Just a normal message with no fence'; + assert.equal(extractCompletionFence(content), null); + }); + + it('should handle fence embedded in larger markdown content', () => { + const content = `Great, let me summarize your answers: + +Here is your profile: + +\`\`\`onboarding-complete +{"q1":"Alice","q2":"Claude","q3":"concise","q4":"minimal","q5":"no spam, no ads, no tracking","q6":"learn rust, ship product, read more","q7":"lists","q8":"preferences and goals","q9":"passwords","q10":"read README, check issues, review structure","q11":"project","q12":"Inbox","q13":"move to archive folder"} +\`\`\` + +Your workspace is being initialized!`; + const result = extractCompletionFence(content); + assert.ok(result); + assert.equal(result!.type, 'onboarding'); + }); +}); + +// ─── Robust JSON Parsing ───────────────────────────────────────── + +describe('parseCompletionPayload', () => { + it('should parse valid JSON directly', () => { + const payload = '{"q1":"hello","q2":"world"}'; + const result = parseCompletionPayload(payload); + assert.ok(result); + assert.equal(result!.q1, 'hello'); + assert.equal(result!.q2, 'world'); + }); + + it('should handle JSON with whitespace padding', () => { + const payload = ' {"q1":"hello","q2":"world"} '; + const result = parseCompletionPayload(payload); + assert.ok(result); + assert.equal(result!.q1, 'hello'); + }); + + it('should handle values containing double quotes (escaped)', () => { + const payload = '{"q1":"he said \\"hello\\"","q2":"world"}'; + const result = parseCompletionPayload(payload); + assert.ok(result); + assert.ok(result!.q1.includes('hello')); + }); + + it('should handle values containing unescaped newlines', () => { + // AI might produce a value with literal newlines + const payload = '{"q1":"line1\nline2","q2":"world"}'; + const result = parseCompletionPayload(payload); + assert.ok(result); + assert.ok(result!.q1.includes('line1')); + }); + + it('should handle trailing comma', () => { + const payload = '{"q1":"hello","q2":"world",}'; + const result = parseCompletionPayload(payload); + assert.ok(result); + assert.equal(result!.q1, 'hello'); + }); + + it('should handle markdown bold in values', () => { + const payload = '{"q1":"**Alice**","q2":"world"}'; + const result = parseCompletionPayload(payload); + assert.ok(result); + assert.equal(result!.q1, 'Alice'); + }); + + it('should handle single quotes', () => { + const payload = "{'q1':'hello','q2':'world'}"; + const result = parseCompletionPayload(payload); + assert.ok(result); + assert.equal(result!.q1, 'hello'); + }); + + it('should handle Chinese text in values', () => { + const payload = '{"q1":"小明","q2":"助手","q3":"简洁直接"}'; + const result = parseCompletionPayload(payload); + assert.ok(result); + assert.equal(result!.q1, '小明'); + assert.equal(result!.q3, '简洁直接'); + }); + + it('should extract via regex as last resort for heavily malformed JSON', () => { + // Simulate AI output with mixed formatting issues + const payload = `{ + "q1": "Alice", + "q2": "Claude", + "q3": "concise and direct" + "q4": "minimal interruptions", +}`; + const result = parseCompletionPayload(payload); + assert.ok(result); + assert.equal(result!.q1, 'Alice'); + assert.equal(result!.q2, 'Claude'); + }); + + it('should return null for completely unparseable content', () => { + const payload = 'this is not json at all'; + assert.equal(parseCompletionPayload(payload), null); + }); + + it('should handle full 13-question payload with free-text answers', () => { + const payload = '{"q1":"小明","q2":"助手","q3":"简洁直接","q4":"主动建议","q5":"不要删除文件、不要发送邮件、不要修改系统设置","q6":"学Rust、发布产品、多读书","q7":"列表","q8":"偏好和目标","q9":"密码和私钥","q10":"读README、看issues、了解结构","q11":"按项目","q12":"Inbox","q13":"移到archive文件夹"}'; + const result = parseCompletionPayload(payload); + assert.ok(result); + assert.equal(Object.keys(result!).length, 13); + assert.equal(result!.q1, '小明'); + assert.equal(result!.q13, '移到archive文件夹'); + }); +}); + +// ─── End-to-End extractCompletion ──────────────────────────────── + +describe('extractCompletion (end-to-end)', () => { + it('should extract and parse a valid onboarding completion', () => { + const content = `Here is your summary: + +\`\`\`onboarding-complete +{"q1":"Alice","q2":"Claude","q3":"concise"} +\`\`\` + +Done!`; + const result = extractCompletion(content); + assert.ok(result); + assert.equal(result!.type, 'onboarding'); + assert.equal(result!.answers.q1, 'Alice'); + }); + + it('should extract and parse a valid checkin completion', () => { + const content = '```checkin-complete\n{"q1":"coded all day","q2":"no changes","q3":"remember to review PR"}\n```'; + const result = extractCompletion(content); + assert.ok(result); + assert.equal(result!.type, 'checkin'); + assert.equal(result!.answers.q1, 'coded all day'); + }); + + it('should return null when fence exists but JSON is completely broken', () => { + const content = '```onboarding-complete\nthis is not json\n```'; + assert.equal(extractCompletion(content), null); + }); + + it('should handle CRLF + trailing comma + markdown bold combined', () => { + const content = '```onboarding-complete\r\n{"q1":"**Alice**","q2":"Claude",}\r\n```'; + const result = extractCompletion(content); + assert.ok(result); + assert.equal(result!.answers.q1, 'Alice'); + }); + + it('should handle fence with answers containing commas and colons', () => { + const content = '```onboarding-complete\n{"q1":"Alice, Bob","q5":"no spam, no ads, no tracking: ever"}\n```'; + const result = extractCompletion(content); + assert.ok(result); + assert.equal(result!.answers.q1, 'Alice, Bob'); + assert.ok(result!.answers.q5.includes('no tracking')); + }); +}); + +// ─── Integration with workspace state ──────────────────────────── + +describe('onboarding completion + workspace state integration', () => { + /* eslint-disable @typescript-eslint/no-require-imports */ + const fs = require('fs'); + const path = require('path'); + const os = require('os'); + + // Set a temp data dir before importing db module + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'onboarding-int-test-')); + process.env.CLAUDE_GUI_DATA_DIR = tmpDir; + + const { + initializeWorkspace, + loadState, + saveState, + needsDailyCheckIn, + } = require('../../lib/assistant-workspace') as typeof import('../../lib/assistant-workspace'); + const { getLocalDateString } = require('../../lib/utils') as typeof import('../../lib/utils'); + /* eslint-enable @typescript-eslint/no-require-imports */ + + it('onboarding success should set onboardingComplete=true and lastCheckInDate=today', () => { + const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'onb-state-')); + initializeWorkspace(workDir); + + // Simulate what onboarding route does + const today = getLocalDateString(); + const state = loadState(workDir); + state.onboardingComplete = true; + state.lastCheckInDate = today; + state.schemaVersion = 3; + saveState(workDir, state); + + const reloaded = loadState(workDir); + assert.equal(reloaded.onboardingComplete, true); + assert.equal(reloaded.lastCheckInDate, today); + assert.equal(needsDailyCheckIn(reloaded), false, 'Should not need check-in on onboarding day'); + + fs.rmSync(workDir, { recursive: true, force: true }); + }); + + it('after onboarding, next day should need check-in', () => { + const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'onb-checkin-')); + initializeWorkspace(workDir); + + const state = loadState(workDir); + state.onboardingComplete = true; + state.lastCheckInDate = '2020-01-01'; // yesterday or earlier + state.schemaVersion = 3; + saveState(workDir, state); + + const reloaded = loadState(workDir); + assert.equal(needsDailyCheckIn(reloaded), true); + + fs.rmSync(workDir, { recursive: true, force: true }); + }); + + it('re-entering session after onboarding should not re-trigger', () => { + const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'onb-reenter-')); + initializeWorkspace(workDir); + + const today = getLocalDateString(); + const state = loadState(workDir); + state.onboardingComplete = true; + state.lastCheckInDate = today; + saveState(workDir, state); + + // Simulate re-entering: check trigger conditions + const reloaded = loadState(workDir); + const needsOnboarding = !reloaded.onboardingComplete; + const needsCheckIn = reloaded.onboardingComplete && reloaded.lastCheckInDate !== today; + assert.equal(needsOnboarding, false); + assert.equal(needsCheckIn, false); + + fs.rmSync(workDir, { recursive: true, force: true }); + }); +}); diff --git a/src/__tests__/unit/skill-kind.test.ts b/src/__tests__/unit/skill-kind.test.ts new file mode 100644 index 0000000..8873c61 --- /dev/null +++ b/src/__tests__/unit/skill-kind.test.ts @@ -0,0 +1,319 @@ +/** + * Unit tests for the SkillKind system — type values, COMMAND_PROMPTS mapping, + * and badge dispatch logic. + * + * Run with: npx tsx --test src/__tests__/unit/skill-kind.test.ts + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import type { SkillKind } from '../../types'; + +// ─── SkillKind Type Values ────────────────────────────────────── + +describe('SkillKind type values', () => { + // SkillKind is a string literal union — we can't iterate it at runtime, + // but we can verify the known values satisfy the type constraint. + const validKinds: SkillKind[] = [ + 'agent_skill', + 'slash_command', + 'sdk_command', + 'codepilot_command', + ]; + + it('should include exactly 4 expected kinds', () => { + assert.equal(validKinds.length, 4); + }); + + it('each kind should be a non-empty string', () => { + for (const kind of validKinds) { + assert.equal(typeof kind, 'string'); + assert.ok(kind.length > 0, `kind "${kind}" should be non-empty`); + } + }); + + it('should have no duplicate values', () => { + const unique = new Set(validKinds); + assert.equal(unique.size, validKinds.length, 'SkillKind values must be unique'); + }); +}); + +// ─── COMMAND_PROMPTS Mapping ──────────────────────────────────── + +// Mirror of the COMMAND_PROMPTS from MessageInput.tsx (not exported, so we replicate) +const COMMAND_PROMPTS: Record = { + '/doctor': 'Run diagnostic checks on this project. Check system health, dependencies, configuration files, and report any issues.', + '/terminal-setup': 'Help me configure my terminal for optimal use with Claude Code. Check current setup and suggest improvements.', + '/memory': 'Show the current CLAUDE.md project memory file and help me review or edit it.', +}; + +// Mirror of built-in commands with kind info (subset relevant to this test) +interface BuiltInCommandStub { + label: string; + value: string; + kind?: SkillKind; + immediate?: boolean; +} + +const BUILT_IN_COMMANDS: BuiltInCommandStub[] = [ + { label: 'help', value: '/help', immediate: true }, + { label: 'clear', value: '/clear', immediate: true }, + { label: 'cost', value: '/cost', immediate: true }, + { label: 'compact', value: '/compact', kind: 'sdk_command' }, + { label: 'doctor', value: '/doctor', kind: 'codepilot_command' }, + { label: 'init', value: '/init', kind: 'sdk_command' }, + { label: 'review', value: '/review', kind: 'sdk_command' }, + { label: 'terminal-setup', value: '/terminal-setup', kind: 'codepilot_command' }, + { label: 'memory', value: '/memory', kind: 'codepilot_command' }, +]; + +describe('COMMAND_PROMPTS mapping', () => { + it('should only have entries for codepilot_command kind', () => { + const codepilotCommands = BUILT_IN_COMMANDS + .filter(c => c.kind === 'codepilot_command') + .map(c => c.value); + + for (const key of Object.keys(COMMAND_PROMPTS)) { + assert.ok( + codepilotCommands.includes(key), + `COMMAND_PROMPTS key "${key}" should correspond to a codepilot_command`, + ); + } + }); + + it('every codepilot_command should have an expansion prompt', () => { + const codepilotCommands = BUILT_IN_COMMANDS.filter(c => c.kind === 'codepilot_command'); + for (const cmd of codepilotCommands) { + assert.ok( + COMMAND_PROMPTS[cmd.value], + `codepilot_command "${cmd.value}" should have an expansion prompt in COMMAND_PROMPTS`, + ); + } + }); + + it('sdk_command entries should NOT be in COMMAND_PROMPTS', () => { + const sdkCommands = BUILT_IN_COMMANDS.filter(c => c.kind === 'sdk_command'); + for (const cmd of sdkCommands) { + assert.equal( + COMMAND_PROMPTS[cmd.value], + undefined, + `sdk_command "${cmd.value}" should not have an expansion prompt`, + ); + } + }); + + it('immediate commands should not have a kind', () => { + const immediateCommands = BUILT_IN_COMMANDS.filter(c => c.immediate); + for (const cmd of immediateCommands) { + assert.equal(cmd.kind, undefined, `immediate command "${cmd.value}" should not have a kind`); + } + }); + + it('all expansion prompts should be non-empty strings', () => { + for (const [key, prompt] of Object.entries(COMMAND_PROMPTS)) { + assert.equal(typeof prompt, 'string'); + assert.ok(prompt.length > 10, `Prompt for "${key}" should be a meaningful sentence`); + } + }); +}); + +// ─── Badge Dispatch Logic ─────────────────────────────────────── + +// Pure-function extraction of the switch(badge.kind) dispatch from handleSubmit +interface CommandBadge { + command: string; + label: string; + description: string; + kind: SkillKind; +} + +interface DispatchResult { + prompt: string; + displayLabel: string; +} + +/** + * Mimics the badge dispatch logic from MessageInput.tsx handleSubmit. + * Extracted as a pure function for testability. + */ +function dispatchBadge(badge: CommandBadge, userContent: string): DispatchResult { + const displayLabel = `/${badge.label}`; + + switch (badge.kind) { + case 'agent_skill': { + // Agent skills: always include skill name so Claude knows which skill was selected + const agentPrompt = userContent + ? `Use the ${badge.label} skill. User context: ${userContent}` + : `Please use the ${badge.label} skill.`; + return { prompt: agentPrompt, displayLabel }; + } + + case 'slash_command': + case 'sdk_command': { + const slashPrompt = userContent + ? `${badge.command} ${userContent}` + : badge.command; + return { prompt: slashPrompt, displayLabel }; + } + + case 'codepilot_command': { + const expandedPrompt = COMMAND_PROMPTS[badge.command] || ''; + const finalPrompt = userContent + ? `${expandedPrompt}\n\nUser context: ${userContent}` + : expandedPrompt || badge.command; + return { prompt: finalPrompt, displayLabel }; + } + } +} + +describe('badge dispatch logic', () => { + describe('agent_skill kind', () => { + const badge: CommandBadge = { + command: '/git-commit', + label: 'git-commit', + description: 'Smart git commit', + kind: 'agent_skill', + }; + + it('should include skill name AND user context when context provided', () => { + const result = dispatchBadge(badge, 'fix the typo in README'); + assert.ok(result.prompt.includes('git-commit'), 'prompt must reference the skill name'); + assert.ok(result.prompt.includes('fix the typo in README'), 'prompt must include user context'); + assert.equal(result.displayLabel, '/git-commit'); + }); + + it('should send trigger hint with skill name when no user content', () => { + const result = dispatchBadge(badge, ''); + assert.equal(result.prompt, 'Please use the git-commit skill.'); + assert.equal(result.displayLabel, '/git-commit'); + }); + + it('should NOT include SKILL.md content but SHOULD include skill name', () => { + const result = dispatchBadge(badge, 'some context'); + assert.ok(!result.prompt.includes('SKILL.md'), 'agent_skill should not expand SKILL.md'); + assert.ok(result.prompt.includes('git-commit'), 'agent_skill must include skill name'); + }); + }); + + describe('slash_command kind', () => { + const badge: CommandBadge = { + command: '/my-custom-skill', + label: 'my-custom-skill', + description: 'A custom slash command', + kind: 'slash_command', + }; + + it('should send /{command} {context} format with user content', () => { + const result = dispatchBadge(badge, 'extra context'); + assert.equal(result.prompt, '/my-custom-skill extra context'); + }); + + it('should send just the command when no user content', () => { + const result = dispatchBadge(badge, ''); + assert.equal(result.prompt, '/my-custom-skill'); + }); + + it('should show display label with slash prefix', () => { + const result = dispatchBadge(badge, ''); + assert.equal(result.displayLabel, '/my-custom-skill'); + }); + }); + + describe('sdk_command kind', () => { + const badge: CommandBadge = { + command: '/compact', + label: 'compact', + description: 'Compress conversation context', + kind: 'sdk_command', + }; + + it('should send /{command} {context} format with user content', () => { + const result = dispatchBadge(badge, 'keep last 5 messages'); + assert.equal(result.prompt, '/compact keep last 5 messages'); + }); + + it('should send just the command when no user content', () => { + const result = dispatchBadge(badge, ''); + assert.equal(result.prompt, '/compact'); + }); + + it('should behave identically to slash_command', () => { + const slashBadge: CommandBadge = { ...badge, kind: 'slash_command' }; + const sdkResult = dispatchBadge(badge, 'test input'); + const slashResult = dispatchBadge(slashBadge, 'test input'); + assert.equal(sdkResult.prompt, slashResult.prompt); + }); + }); + + describe('codepilot_command kind', () => { + const badge: CommandBadge = { + command: '/doctor', + label: 'doctor', + description: 'Diagnose project health', + kind: 'codepilot_command', + }; + + it('should expand via COMMAND_PROMPTS when no user content', () => { + const result = dispatchBadge(badge, ''); + assert.equal(result.prompt, COMMAND_PROMPTS['/doctor']); + assert.ok(result.prompt.includes('diagnostic checks')); + }); + + it('should append user context to expanded prompt', () => { + const result = dispatchBadge(badge, 'focus on deps'); + assert.ok(result.prompt.startsWith(COMMAND_PROMPTS['/doctor'])); + assert.ok(result.prompt.includes('User context: focus on deps')); + }); + + it('should fall back to command string when no expansion prompt exists', () => { + const unknownBadge: CommandBadge = { + command: '/unknown-codepilot', + label: 'unknown-codepilot', + description: 'Unknown', + kind: 'codepilot_command', + }; + const result = dispatchBadge(unknownBadge, ''); + assert.equal(result.prompt, '/unknown-codepilot'); + }); + + it('should NOT send raw command for known codepilot commands', () => { + const result = dispatchBadge(badge, ''); + assert.ok(!result.prompt.startsWith('/doctor'), 'should expand, not send raw /doctor'); + }); + + it('should show display label correctly', () => { + const result = dispatchBadge(badge, ''); + assert.equal(result.displayLabel, '/doctor'); + }); + }); + + describe('cross-kind consistency', () => { + it('all kinds should produce a displayLabel starting with /', () => { + const kinds: SkillKind[] = ['agent_skill', 'slash_command', 'sdk_command', 'codepilot_command']; + for (const kind of kinds) { + const badge: CommandBadge = { + command: '/test', + label: 'test', + description: 'test', + kind, + }; + const result = dispatchBadge(badge, ''); + assert.ok(result.displayLabel.startsWith('/'), `kind "${kind}" should produce /label`); + } + }); + + it('all kinds should produce a non-empty prompt', () => { + const kinds: SkillKind[] = ['agent_skill', 'slash_command', 'sdk_command', 'codepilot_command']; + for (const kind of kinds) { + const badge: CommandBadge = { + command: '/test', + label: 'test', + description: 'test', + kind, + }; + const result = dispatchBadge(badge, ''); + assert.ok(result.prompt.length > 0, `kind "${kind}" should produce a non-empty prompt`); + } + }); + }); +}); diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 7ad1fba..9f15dbc 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -3,6 +3,7 @@ import { streamClaude } from '@/lib/claude-client'; import { addMessage, getMessages, getSession, updateSessionTitle, updateSdkSessionId, updateSessionModel, updateSessionProvider, updateSessionProviderId, getSetting, acquireSessionLock, renewSessionLock, releaseSessionLock, setSessionRuntimeStatus, syncSdkTasks } from '@/lib/db'; import { resolveProvider as resolveProviderUnified } from '@/lib/provider-resolver'; import { notifySessionStart, notifySessionComplete, notifySessionError } from '@/lib/telegram-bot'; +import { extractCompletion } from '@/lib/onboarding-completion'; import type { SendMessageRequest, SSEEvent, TokenUsage, MessageContentBlock, FileAttachment, ClaudeStreamOptions } from '@/types'; import crypto from 'crypto'; import fs from 'fs'; @@ -37,8 +38,8 @@ export async function POST(request: NextRequest) { let activeLockId: string | undefined; try { - const body: SendMessageRequest & { files?: FileAttachment[]; toolTimeout?: number; provider_id?: string; systemPromptAppend?: string; autoTrigger?: boolean; thinking?: unknown; effort?: string; enableFileCheckpointing?: boolean } = await request.json(); - const { session_id, content, model, mode, files, toolTimeout, provider_id, systemPromptAppend, autoTrigger, thinking, effort, enableFileCheckpointing } = body; + const body: SendMessageRequest & { files?: FileAttachment[]; toolTimeout?: number; provider_id?: string; systemPromptAppend?: string; autoTrigger?: boolean; thinking?: unknown; effort?: string; enableFileCheckpointing?: boolean; displayOverride?: string } = await request.json(); + const { session_id, content, model, mode, files, toolTimeout, provider_id, systemPromptAppend, autoTrigger, thinking, effort, enableFileCheckpointing, displayOverride } = body; console.log('[chat API] content length:', content.length, 'first 200 chars:', content.slice(0, 200)); console.log('[chat API] systemPromptAppend:', systemPromptAppend ? `${systemPromptAppend.length} chars` : 'none'); @@ -81,7 +82,8 @@ export async function POST(request: NextRequest) { // Save user message — persist file metadata so attachments survive page reload // Skip saving for autoTrigger messages (invisible system triggers for assistant hooks) - let savedContent = content; + // Use displayOverride for DB storage if provided (e.g. /skillName instead of expanded prompt) + let savedContent = displayOverride || content; let fileMeta: Array<{ id: string; name: string; type: string; size: number; filePath: string }> | undefined; if (!autoTrigger) { if (files && files.length > 0) { @@ -97,7 +99,7 @@ export async function POST(request: NextRequest) { fs.writeFileSync(filePath, buffer); return { id: f.id, name: f.name, type: f.type, size: buffer.length, filePath }; }); - savedContent = `${content}`; + savedContent = `${displayOverride || content}`; } addMessage(session_id, 'user', savedContent); @@ -240,12 +242,21 @@ Ask the following 13 questions ONE AT A TIME. Wait for the user's answer before 12. Where should new information go by default? 13. How should completed tasks be archived? -After all questions are answered, output a summary of the collected answers in exactly this format — this is critical for the system to process your answers: +After the user answers the LAST question (Q13), you MUST immediately output the completion block below. Do NOT wait for the user to say anything else. Do NOT ask for confirmation. Just output the block right after your response to Q13. + +CRITICAL FORMATTING RULES for the completion block: +- Each value must be a single line (replace any newlines with spaces) +- Escape all double quotes inside values with backslash: \\" +- Do NOT use single quotes for JSON keys or values +- Do NOT add trailing commas +- The JSON must be on a SINGLE line \`\`\`onboarding-complete {"q1":"answer1","q2":"answer2","q3":"answer3","q4":"answer4","q5":"answer5","q6":"answer6","q7":"answer7","q8":"answer8","q9":"answer9","q10":"answer10","q11":"answer11","q12":"answer12","q13":"answer13"} \`\`\` +After outputting the completion block, tell the user that the setup is complete and the system is now initializing their workspace. Keep this message brief and friendly. + Do NOT try to write files yourself. The system will automatically generate soul.md, user.md, claude.md, memory.md, config.json, and taxonomy.json from your collected answers. Start by greeting the user and asking the first question. @@ -555,6 +566,27 @@ async function collectStreamResponse( } } } finally { + // ── Server-side completion detection (reliable path) ── + // After persisting the assistant message, check for onboarding/checkin + // fences and process them directly on the server. This ensures completion + // is captured even if the frontend misses it (page refresh, parse failure, etc.). + try { + const fullText = contentBlocks + .filter((b): b is Extract => b.type === 'text') + .map((b) => b.text) + .join(''); + const completion = extractCompletion(fullText); + if (completion) { + const workspacePath = getSetting('assistant_workspace_path'); + const session = getSession(sessionId); + if (workspacePath && session && session.working_directory === workspacePath) { + await processCompletionServerSide(completion, workspacePath, sessionId); + } + } + } catch (e) { + console.error('[chat API] Server-side completion detection failed:', e); + } + // Telegram notifications: completion or error (fire-and-forget) if (hasError) { notifySessionError(errorMessage, telegramOpts).catch(() => {}); @@ -570,3 +602,47 @@ async function collectStreamResponse( onComplete?.(); } } + +/** + * Process a detected onboarding/checkin completion on the server side. + * Calls the shared processor functions directly — no HTTP round-trip needed. + * + * Both processors are internally idempotent: + * - processOnboarding checks state.onboardingComplete + * - processCheckin checks state.lastCheckInDate === today + */ +async function processCompletionServerSide( + completion: import('@/lib/onboarding-completion').ExtractedCompletion, + _workspacePath: string, + sessionId: string, +): Promise { + try { + if (completion.type === 'onboarding') { + const { processOnboarding } = await import('@/lib/onboarding-processor'); + console.log('[chat API] Server-side onboarding completion detected'); + await processOnboarding(completion.answers, sessionId); + console.log('[chat API] Server-side onboarding completion succeeded'); + } else if (completion.type === 'checkin') { + const { processCheckin } = await import('@/lib/checkin-processor'); + console.log('[chat API] Server-side checkin completion detected'); + await processCheckin(completion.answers, sessionId); + console.log('[chat API] Server-side checkin completion succeeded'); + } + + // Clear hookTriggeredSessionId directly (no HTTP needed) + try { + const { loadState, saveState } = await import('@/lib/assistant-workspace'); + const { getSetting: getSettingDirect } = await import('@/lib/db'); + const wsPath = getSettingDirect('assistant_workspace_path'); + if (wsPath) { + const state = loadState(wsPath); + state.hookTriggeredSessionId = undefined; + saveState(wsPath, state); + } + } catch { + // Best effort + } + } catch (e) { + console.error(`[chat API] Server-side ${completion.type} processing failed:`, e); + } +} diff --git a/src/app/api/plugins/mcp/[name]/route.ts b/src/app/api/plugins/mcp/[name]/route.ts index 659a6bf..4cb53cd 100644 --- a/src/app/api/plugins/mcp/[name]/route.ts +++ b/src/app/api/plugins/mcp/[name]/route.ts @@ -8,23 +8,26 @@ function getSettingsPath(): string { return path.join(os.homedir(), '.claude', 'settings.json'); } -function readSettings(): Record { - const settingsPath = getSettingsPath(); - if (!fs.existsSync(settingsPath)) return {}; +// ~/.claude.json — Claude CLI stores user-scoped MCP servers here +function getUserConfigPath(): string { + return path.join(os.homedir(), '.claude.json'); +} + +function readJsonFile(filePath: string): Record { + if (!fs.existsSync(filePath)) return {}; try { - return JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); + return JSON.parse(fs.readFileSync(filePath, 'utf-8')); } catch { return {}; } } -function writeSettings(settings: Record): void { - const settingsPath = getSettingsPath(); - const dir = path.dirname(settingsPath); +function writeJsonFile(filePath: string, data: Record): void { + const dir = path.dirname(filePath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } - fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8'); + fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8'); } export async function DELETE( @@ -35,20 +38,35 @@ export async function DELETE( const { name } = await params; const serverName = decodeURIComponent(name); - const settings = readSettings(); - const mcpServers = (settings.mcpServers || {}) as Record; + let deleted = false; - if (!mcpServers[serverName]) { + // Try deleting from ~/.claude/settings.json + const settings = readJsonFile(getSettingsPath()); + const settingsServers = (settings.mcpServers || {}) as Record; + if (settingsServers[serverName]) { + delete settingsServers[serverName]; + settings.mcpServers = settingsServers; + writeJsonFile(getSettingsPath(), settings); + deleted = true; + } + + // Also try deleting from ~/.claude.json + const userConfig = readJsonFile(getUserConfigPath()); + const userServers = (userConfig.mcpServers || {}) as Record; + if (userServers[serverName]) { + delete userServers[serverName]; + userConfig.mcpServers = userServers; + writeJsonFile(getUserConfigPath(), userConfig); + deleted = true; + } + + if (!deleted) { return NextResponse.json( { error: `MCP server "${serverName}" not found` }, { status: 404 } ); } - delete mcpServers[serverName]; - settings.mcpServers = mcpServers; - writeSettings(settings); - return NextResponse.json({ success: true }); } catch (error) { return NextResponse.json( diff --git a/src/app/api/plugins/mcp/route.ts b/src/app/api/plugins/mcp/route.ts index 18f968b..acdd48a 100644 --- a/src/app/api/plugins/mcp/route.ts +++ b/src/app/api/plugins/mcp/route.ts @@ -44,11 +44,18 @@ export async function GET(): Promise; + const userConfigServers = (userConfig.mcpServers || {}) as Record; + // Merge: ~/.claude/settings.json takes precedence over ~/.claude.json - const mcpServers = { - ...((userConfig.mcpServers || {}) as Record), - ...((settings.mcpServers || {}) as Record), - }; + // Tag each server with _source so UI knows where it came from + const mcpServers: Record = {}; + for (const [name, server] of Object.entries(userConfigServers)) { + mcpServers[name] = { ...server, _source: 'claude.json' }; + } + for (const [name, server] of Object.entries(settingsServers)) { + mcpServers[name] = { ...server, _source: 'settings.json' }; + } return NextResponse.json({ mcpServers }); } catch (error) { return NextResponse.json( @@ -63,12 +70,38 @@ export async function PUT( ): Promise> { try { const body = await request.json(); - const { mcpServers } = body as { mcpServers: Record }; + const incoming = body.mcpServers as Record; + // Split incoming servers by source and write to the correct file. + // Servers without _source or with _source='settings.json' → settings.json + // Servers with _source='claude.json' → ~/.claude.json + const forSettings: Record = {}; + const forUserConfig: Record = {}; + + for (const [name, server] of Object.entries(incoming)) { + const { _source, ...cleanServer } = server; + if (_source === 'claude.json') { + forUserConfig[name] = cleanServer; + } else { + forSettings[name] = cleanServer; + } + } + + // Write settings.json const settings = readSettings(); - settings.mcpServers = mcpServers; + settings.mcpServers = forSettings; writeSettings(settings); + // Write ~/.claude.json (only the mcpServers key, preserve other fields) + const userConfig = readJsonFile(getUserConfigPath()); + userConfig.mcpServers = forUserConfig; + const userConfigPath = getUserConfigPath(); + const dir = path.dirname(userConfigPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(userConfigPath, JSON.stringify(userConfig, null, 2), 'utf-8'); + return NextResponse.json({ success: true }); } catch (error) { return NextResponse.json( @@ -85,26 +118,33 @@ export async function POST( const body = await request.json(); const { name, server } = body as { name: string; server: MCPServerConfig }; - if (!name || !server || !server.command) { + // stdio servers require command; sse/http servers require url + const isRemote = server?.type === 'sse' || server?.type === 'http'; + if (!name || !server || (!isRemote && !server.command) || (isRemote && !server.url)) { return NextResponse.json( - { error: 'Name and server command are required' }, + { error: isRemote ? 'Name and server URL are required' : 'Name and server command are required' }, { status: 400 } ); } + // Check both config files for name collision (merged namespace) const settings = readSettings(); + const userConfig = readJsonFile(getUserConfigPath()); if (!settings.mcpServers) { settings.mcpServers = {}; } - const mcpServers = settings.mcpServers as Record; - if (mcpServers[name]) { + const settingsServers = settings.mcpServers as Record; + const userConfigServers = (userConfig.mcpServers || {}) as Record; + if (settingsServers[name] || userConfigServers[name]) { return NextResponse.json( { error: `MCP server "${name}" already exists` }, { status: 409 } ); } + const mcpServers = settingsServers; + mcpServers[name] = server; writeSettings(settings); diff --git a/src/app/api/skills/[name]/route.ts b/src/app/api/skills/[name]/route.ts index 2f3702d..74cbc2a 100644 --- a/src/app/api/skills/[name]/route.ts +++ b/src/app/api/skills/[name]/route.ts @@ -3,6 +3,7 @@ import fs from "fs"; import path from "path"; import os from "os"; import crypto from "crypto"; +import type { SkillKind } from "@/types"; function getGlobalCommandsDir(): string { return path.join(os.homedir(), ".claude", "commands"); @@ -281,6 +282,8 @@ export async function GET( : firstLine || `Skill: /${name}`; } + const kind: SkillKind = found.filePath.endsWith("SKILL.md") ? "agent_skill" : "slash_command"; + return NextResponse.json({ skill: { name, @@ -289,6 +292,7 @@ export async function GET( source: found.source, installedSource: found.installedSource, filePath: found.filePath, + kind, }, }); } catch (error) { @@ -342,6 +346,8 @@ export async function PUT( ? firstLine.replace(/^#+\s*/, "") : firstLine || `Skill: /${name}`; + const kind: SkillKind = found.filePath.endsWith("SKILL.md") ? "agent_skill" : "slash_command"; + return NextResponse.json({ skill: { name, @@ -350,6 +356,7 @@ export async function PUT( source: found.source, installedSource: found.installedSource, filePath: found.filePath, + kind, }, }); } catch (error) { diff --git a/src/app/api/skills/route.ts b/src/app/api/skills/route.ts index 73e1832..7890f05 100644 --- a/src/app/api/skills/route.ts +++ b/src/app/api/skills/route.ts @@ -3,12 +3,14 @@ import fs from "fs"; import path from "path"; import os from "os"; import crypto from "crypto"; +import type { SkillKind } from "@/types"; interface SkillFile { name: string; description: string; content: string; - source: "global" | "project" | "plugin" | "installed"; + source: "global" | "project" | "plugin" | "installed" | "sdk"; + kind: SkillKind; installedSource?: "agents" | "claude"; filePath: string; } @@ -86,6 +88,7 @@ function scanProjectSkills(dir: string): SkillFile[] { description, content, source: "project", + kind: "agent_skill", filePath: skillMdPath, }); } @@ -177,6 +180,7 @@ function scanInstalledSkills( description, content, source: "installed", + kind: "agent_skill", installedSource, contentHash, filePath: skillMdPath, @@ -256,7 +260,7 @@ function scanDirectory( const description = firstLine.startsWith("#") ? firstLine.replace(/^#+\s*/, "") : firstLine || `Skill: /${name}`; - skills.push({ name, description, content, source, filePath }); + skills.push({ name, description, content, source, kind: "slash_command", filePath }); } } catch { // ignore read errors @@ -332,7 +336,8 @@ export async function GET(request: NextRequest) { name: cmd.name, description: cmd.description || `SDK command: /${cmd.name}`, content: '', // SDK commands don't have local content - source: 'sdk' as typeof all[number]['source'], + source: 'sdk', + kind: 'sdk_command', filePath: '', }); } diff --git a/src/app/api/workspace/checkin/route.ts b/src/app/api/workspace/checkin/route.ts index beb6ff9..4ad28f8 100644 --- a/src/app/api/workspace/checkin/route.ts +++ b/src/app/api/workspace/checkin/route.ts @@ -1,11 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import fs from 'fs'; -import path from 'path'; -import { getSetting, getSession } from '@/lib/db'; -import { resolveProvider } from '@/lib/provider-resolver'; -import { loadState, saveState, writeDailyMemory } from '@/lib/assistant-workspace'; -import { getLocalDateString } from '@/lib/utils'; -import { generateTextFromProvider } from '@/lib/text-generator'; +import { processCheckin } from '@/lib/checkin-processor'; const CHECK_IN_QUESTIONS = [ 'assistant.checkInQ1', @@ -31,11 +25,6 @@ export async function GET() { export async function POST(request: NextRequest) { try { - const workspacePath = getSetting('assistant_workspace_path'); - if (!workspacePath) { - return NextResponse.json({ error: 'No workspace path configured' }, { status: 400 }); - } - const body = await request.json(); const { answers, sessionId } = body as { answers: Record; sessionId?: string }; @@ -43,142 +32,14 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Invalid answers format' }, { status: 400 }); } - // Look up the calling session for workspace validation AND provider/model context - let session: ReturnType | undefined; - if (sessionId) { - session = getSession(sessionId) ?? undefined; - if (session && session.working_directory !== workspacePath) { - return NextResponse.json({ error: 'Session does not belong to current workspace' }, { status: 403 }); - } - } - - const qaText = CHECK_IN_LABELS.map((q, i) => { - const key = `q${i + 1}`; - return `Q: ${q}\nA: ${answers[key] || '(skipped)'}`; - }).join('\n\n'); - - const today = getLocalDateString(); - - // Read existing files for context - const memoryPath = path.join(workspacePath, 'memory.md'); - const userPath = path.join(workspacePath, 'user.md'); - let existingMemory = ''; - let existingUser = ''; - try { existingMemory = fs.readFileSync(memoryPath, 'utf-8'); } catch { /* new file */ } - try { existingUser = fs.readFileSync(userPath, 'utf-8'); } catch { /* new file */ } - - try { - // Resolve using session's provider/model so check-in uses the same provider as chat - const resolved = resolveProvider({ - sessionProviderId: session?.provider_id || undefined, - sessionModel: session?.model || undefined, - }); - // Preserve 'env' semantics (see onboarding route for rationale) - const providerId = resolved.provider?.id || 'env'; - const model = resolved.upstreamModel || resolved.model || getSetting('default_model') || 'claude-sonnet-4-20250514'; - - // Generate daily memory entry (episodic, not destructive) - const dailyMemoryPrompt = `You maintain daily memory entries for an AI assistant. Given the user's daily check-in answers, generate a daily memory entry for ${today}. - -Format it with these sections: -## Work Log -(What the user accomplished today) - -## Priority Changes -(Any shifts in goals or priorities) - -## To Remember -(Things the user wants remembered) - -## Candidate Long-Term Memory -(Facts that seem stable enough to promote to long-term memory — only include genuinely persistent facts, not transient updates) - -Keep under 2000 characters. - -Today's check-in (${today}): -${qaText}`; - - // Generate promotion candidates: only stable facts to append to memory.md - const promotionPrompt = `You maintain a long-term memory file for an AI assistant. Given the user's check-in and the existing memory, output ONLY new stable facts that should be APPENDED to memory.md. These must be genuinely persistent facts (user preferences, recurring patterns, important relationships), NOT daily transients. - -If there's nothing worth promoting, output exactly: (nothing to promote) - -Existing memory.md: -${existingMemory || '(empty)'} - -Today's check-in (${today}): -${qaText}`; - - // Generate incremental user.md updates - const userPrompt = `You maintain a user.md profile for an AI assistant. Given the user's daily check-in answers and the existing profile, generate an UPDATED user.md. Only update sections affected by today's answers. Keep it organized with markdown headers. Keep under 2000 characters. - -Existing user.md: -${existingUser || '(empty)'} - -Today's check-in (${today}): -${qaText}`; - - const [dailyContent, promotionContent, newUser] = await Promise.all([ - generateTextFromProvider({ providerId, model, system: 'You maintain knowledge files for AI assistants. Output only the file content, no explanations.', prompt: dailyMemoryPrompt }), - generateTextFromProvider({ providerId, model, system: 'You maintain knowledge files for AI assistants. Output only the content to append, no explanations.', prompt: promotionPrompt }), - generateTextFromProvider({ providerId, model, system: 'You maintain user profile documents. Output only the file content, no explanations.', prompt: userPrompt }), - ]); - - // Write daily memory file (episodic, per-day) - if (dailyContent.trim()) { - writeDailyMemory(workspacePath, today, dailyContent); - } - - // Promote stable facts to memory.md (additive append, NOT rewrite) - // Dedup: skip if today's promotion already exists in memory.md - if (promotionContent.trim() && !promotionContent.includes('(nothing to promote)')) { - const currentMemory = fs.existsSync(memoryPath) ? fs.readFileSync(memoryPath, 'utf-8') : ''; - if (!currentMemory.includes(`## Promoted from ${today}`)) { - const appendText = `\n\n## Promoted from ${today}\n${promotionContent.trim()}\n`; - fs.appendFileSync(memoryPath, appendText, 'utf-8'); - } - } - - // Incremental user.md update - if (existingUser && newUser.trim()) { - fs.writeFileSync(userPath, newUser, 'utf-8'); - } - - // Archive old daily memories and promote candidates - try { - const { archiveDailyMemories, promoteDailyToLongTerm } = await import('@/lib/workspace-organizer'); - archiveDailyMemories(workspacePath); - - // Promote memories that are 7+ days old - const sevenDaysAgo = new Date(); - sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); - const dailyDir = path.join(workspacePath, 'memory', 'daily'); - if (fs.existsSync(dailyDir)) { - const oldFiles = fs.readdirSync(dailyDir) - .filter(f => /^\d{4}-\d{2}-\d{2}\.md$/.test(f)) - .filter(f => f.replace('.md', '') <= getLocalDateString(sevenDaysAgo)); - for (const f of oldFiles) { - promoteDailyToLongTerm(workspacePath, f.replace('.md', '')); - } - } - } catch { - // organizer module not available, skip archival - } - } catch (e) { - console.warn('[workspace/checkin] AI generation failed, writing raw daily entry:', e); - // Fallback: write raw answers as daily memory - const rawDailyContent = `# Daily Check-in ${today}\n\n${qaText}\n`; - writeDailyMemory(workspacePath, today, rawDailyContent); - } - - // Update state - const state = loadState(workspacePath); - state.lastCheckInDate = today; - saveState(workspacePath, state); - + await processCheckin(answers, sessionId); return NextResponse.json({ success: true }); } catch (e) { console.error('[workspace/checkin] POST failed:', e); - return NextResponse.json({ error: 'Check-in failed' }, { status: 500 }); + const message = e instanceof Error ? e.message : 'Check-in failed'; + const status = message.includes('No workspace path') ? 400 + : message.includes('does not belong') ? 403 + : 500; + return NextResponse.json({ error: message }, { status }); } } diff --git a/src/app/api/workspace/onboarding/route.ts b/src/app/api/workspace/onboarding/route.ts index 01b659f..51bfc77 100644 --- a/src/app/api/workspace/onboarding/route.ts +++ b/src/app/api/workspace/onboarding/route.ts @@ -1,11 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import fs from 'fs'; -import path from 'path'; -import { getSetting, getSession } from '@/lib/db'; -import { resolveProvider } from '@/lib/provider-resolver'; -import { loadState, saveState, ensureDailyDir, generateRootDocs } from '@/lib/assistant-workspace'; -import { getLocalDateString } from '@/lib/utils'; -import { generateTextFromProvider } from '@/lib/text-generator'; +import { processOnboarding } from '@/lib/onboarding-processor'; const QUESTIONS = [ 'assistant.onboardingQ1', @@ -51,11 +45,6 @@ export async function GET() { export async function POST(request: NextRequest) { try { - const workspacePath = getSetting('assistant_workspace_path'); - if (!workspacePath) { - return NextResponse.json({ error: 'No workspace path configured' }, { status: 400 }); - } - const body = await request.json(); const { answers, sessionId } = body as { answers: Record; sessionId?: string }; @@ -63,142 +52,14 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Invalid answers format' }, { status: 400 }); } - // Look up the calling session for workspace validation AND provider/model context - let session: ReturnType | undefined; - if (sessionId) { - session = getSession(sessionId) ?? undefined; - if (session && session.working_directory !== workspacePath) { - return NextResponse.json({ error: 'Session does not belong to current workspace' }, { status: 403 }); - } - } - - // Build Q&A text for the prompt - const qaText = QUESTION_LABELS.map((q, i) => { - const key = `q${i + 1}`; - return `Q: ${q}\nA: ${answers[key] || '(skipped)'}`; - }).join('\n\n'); - - let soulContent: string; - let userContent: string; - let claudeContent: string; - let memoryContent: string; - - try { - // Resolve using session's provider/model so onboarding uses the same provider as chat - const resolved = resolveProvider({ - sessionProviderId: session?.provider_id || undefined, - sessionModel: session?.model || undefined, - }); - // Preserve 'env' semantics: when provider=undefined, pass 'env' so text-generator - // uses shell env credentials instead of falling back to a random DB provider - const providerId = resolved.provider?.id || 'env'; - const model = resolved.upstreamModel || resolved.model || getSetting('default_model') || 'claude-sonnet-4-20250514'; - - const soulPrompt = `Based on the following user onboarding answers, generate a concise "soul.md" file that defines an AI assistant's personality, communication style, and behavioral rules. Write in second person ("You are..."). Keep it under 2000 characters. Use markdown headers and bullet points.\n\n${qaText}`; - - const userPrompt = `Based on the following user onboarding answers, generate a concise "user.md" profile that captures the user's preferences, goals, and boundaries. Write in third person. Keep it under 2000 characters. Use markdown headers and bullet points.\n\n${qaText}`; - - const claudePrompt = `Based on the following user onboarding answers, generate a "claude.md" rules file for an AI assistant. Include: -- Execution rules (what to do when entering a project, based on Q10) -- Communication style rules (based on Q3, Q4, Q7) -- Memory rules (what to remember/forget, based on Q8, Q9) -- Hard boundaries (based on Q5) -Keep it under 2000 characters. Use markdown headers and bullet points.\n\n${qaText}`; - - const memoryPrompt = `Based on the following user onboarding answers, generate an initial "memory.md" file with long-term facts about the user worth remembering. Include user goals, preferences, and any stable facts. Keep it under 1000 characters. Use markdown headers.\n\n${qaText}`; - - [soulContent, userContent, claudeContent, memoryContent] = await Promise.all([ - generateTextFromProvider({ providerId, model, system: 'You generate configuration files for AI assistants. Output only the file content, no explanations.', prompt: soulPrompt }), - generateTextFromProvider({ providerId, model, system: 'You generate user profile documents. Output only the file content, no explanations.', prompt: userPrompt }), - generateTextFromProvider({ providerId, model, system: 'You generate configuration files for AI assistants. Output only the file content, no explanations.', prompt: claudePrompt }), - generateTextFromProvider({ providerId, model, system: 'You generate knowledge files for AI assistants. Output only the file content, no explanations.', prompt: memoryPrompt }), - ]); - - // Fallback if AI returned empty - if (!soulContent.trim() || !userContent.trim()) { - throw new Error('AI returned empty content'); - } - } catch (e) { - console.warn('[workspace/onboarding] AI generation failed, using raw answers:', e); - // Fallback: write raw answers - soulContent = `# Soul\n\n## Communication Style\n- Address user as: ${answers.q1 || 'not specified'}\n- Assistant name: ${answers.q2 || 'not specified'}\n- Style: ${answers.q3 || 'not specified'}\n- Approach: ${answers.q4 || 'not specified'}\n`; - userContent = `# User Profile\n\n## Preferences\n- Boundaries: ${answers.q5 || 'not specified'}\n- Goals: ${answers.q6 || 'not specified'}\n- Output format: ${answers.q7 || 'not specified'}\n- Memory allowed: ${answers.q8 || 'not specified'}\n- Memory forbidden: ${answers.q9 || 'not specified'}\n- Project entry: ${answers.q10 || 'not specified'}\n- Organization: ${answers.q11 || 'not specified'}\n- Default capture: ${answers.q12 || 'not specified'}\n- Archive policy: ${answers.q13 || 'not specified'}\n`; - claudeContent = `# Rules\n\n## Execution\n- On project entry: ${answers.q10 || 'not specified'}\n\n## Boundaries\n- ${answers.q5 || 'not specified'}\n\n## Memory\n- Allowed: ${answers.q8 || 'not specified'}\n- Forbidden: ${answers.q9 || 'not specified'}\n`; - memoryContent = `# Memory\n\n## User Goals\n- ${answers.q6 || 'not specified'}\n`; - } - - // Write all core files - fs.writeFileSync(path.join(workspacePath, 'soul.md'), soulContent, 'utf-8'); - fs.writeFileSync(path.join(workspacePath, 'user.md'), userContent, 'utf-8'); - if (claudeContent.trim()) { - fs.writeFileSync(path.join(workspacePath, 'claude.md'), claudeContent, 'utf-8'); - } - if (memoryContent.trim()) { - fs.writeFileSync(path.join(workspacePath, 'memory.md'), memoryContent, 'utf-8'); - } - - // Ensure V2 directories - ensureDailyDir(workspacePath); - const inboxDir = path.join(workspacePath, 'Inbox'); - if (!fs.existsSync(inboxDir)) { - fs.mkdirSync(inboxDir, { recursive: true }); - } - - // Generate config.json from answers - try { - const { loadConfig, saveConfig } = await import('@/lib/workspace-config'); - const config = loadConfig(workspacePath); - - // Apply onboarding answers to config - const orgStyle = (answers.q11 || '').toLowerCase(); - if (orgStyle.includes('project')) config.organizationStyle = 'project'; - else if (orgStyle.includes('time')) config.organizationStyle = 'time'; - else if (orgStyle.includes('topic')) config.organizationStyle = 'topic'; - else config.organizationStyle = 'mixed'; - - if (answers.q12) { - // Sanitize captureDefault: only allow relative paths within workspace - let capture = answers.q12.trim(); - if (path.isAbsolute(capture) || capture.startsWith('~') || capture.includes('..')) { - capture = 'Inbox'; // fallback to safe default - } - config.captureDefault = capture; - } - - saveConfig(workspacePath, config); - } catch { - // config module not available, skip - } - - // Generate taxonomy from existing directories - try { - const { loadTaxonomy, saveTaxonomy, inferTaxonomyFromDirs } = await import('@/lib/workspace-taxonomy'); - const taxonomy = loadTaxonomy(workspacePath); - if (taxonomy.categories.length === 0) { - const inferred = inferTaxonomyFromDirs(workspacePath); - if (inferred.length > 0) { - taxonomy.categories = inferred; - saveTaxonomy(workspacePath, taxonomy); - } - } - } catch { - // taxonomy module not available, skip - } - - // Generate root docs - generateRootDocs(workspacePath); - - // Update state - const today = getLocalDateString(); - const state = loadState(workspacePath); - state.onboardingComplete = true; - state.lastCheckInDate = today; // Skip daily check-in on the day of onboarding - state.schemaVersion = 3; - saveState(workspacePath, state); - + await processOnboarding(answers, sessionId); return NextResponse.json({ success: true }); } catch (e) { console.error('[workspace/onboarding] POST failed:', e); - return NextResponse.json({ error: 'Onboarding failed' }, { status: 500 }); + const message = e instanceof Error ? e.message : 'Onboarding failed'; + const status = message.includes('No workspace path') ? 400 + : message.includes('does not belong') ? 403 + : 500; + return NextResponse.json({ error: message }, { status }); } } diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index e2d6feb..eeed6fb 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -98,7 +98,7 @@ export default function NewChatPage() { }, [pendingPermission, setPendingApprovalSessionId]); const sendFirstMessage = useCallback( - async (content: string, _files?: unknown, systemPromptAppend?: string) => { + async (content: string, _files?: unknown, systemPromptAppend?: string, displayOverride?: string) => { if (isStreaming) return; // Require a project directory before sending @@ -152,12 +152,12 @@ export default function NewChatPage() { // Notify ChatListPanel to refresh immediately window.dispatchEvent(new CustomEvent('session-created')); - // Add user message to UI + // Add user message to UI — use displayOverride for chat bubble if provided const userMessage: Message = { id: 'temp-' + Date.now(), session_id: session.id, role: 'user', - content, + content: displayOverride || content, created_at: new Date().toISOString(), token_usage: null, }; @@ -181,6 +181,7 @@ export default function NewChatPage() { ...(systemPromptAppend ? { systemPromptAppend } : {}), ...(selectedEffort ? { effort: selectedEffort } : {}), ...(thinkingConfig ? { thinking: thinkingConfig } : {}), + ...(displayOverride ? { displayOverride } : {}), }), signal: controller.signal, }); diff --git a/src/components/chat/ChatView.tsx b/src/components/chat/ChatView.tsx index 7ab2a66..2f0545e 100644 --- a/src/components/chat/ChatView.tsx +++ b/src/components/chat/ChatView.tsx @@ -102,6 +102,8 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal const pendingImageNoticesRef = useRef([]); // Ref for sendMessage to allow self-referencing in timeout auto-retry const sendMessageRef = useRef<(content: string, files?: FileAttachment[]) => Promise>(undefined); + // Init metadata from SDK (tools, slash_commands, skills) — populated on first status event + const initMetaRef = useRef<{ tools?: unknown; slash_commands?: unknown; skills?: unknown } | null>(null); const handleModeChange = useCallback((newMode: string) => { setMode(newMode); @@ -151,7 +153,8 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal // If stream completed while this ChatView was unmounted, consume finalMessageContent now. // Re-fetch messages from DB to avoid duplicates (backend already persisted the reply). if (existing.phase !== 'active' && existing.finalMessageContent) { - detectAssistantCompletion(existing.finalMessageContent); + // Completion processing (onboarding/checkin) is handled server-side only + // in the chat API's finally block — no frontend duplicate needed. fetch(`/api/chat/sessions/${sessionId}/messages?limit=50`) .then(res => res.ok ? res.json() : null) .then(data => { @@ -200,8 +203,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal // Append the final assistant message to the messages list const finalContent = event.snapshot.finalMessageContent; if (finalContent) { - // Check for assistant project completion signals - detectAssistantCompletion(finalContent); + // Completion processing (onboarding/checkin) is handled server-side only. const assistantMessage: Message = { id: 'temp-assistant-' + Date.now(), @@ -278,6 +280,41 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal if (!needsOnboarding && !needsCheckIn) return; + // ── Compensation: check if a past message already contains a completion fence ── + // This handles the case where the server-side detection also missed (e.g. crash/restart) + // and the frontend is about to re-trigger onboarding unnecessarily. + if (needsOnboarding && initialMessages.length > 0) { + try { + const { extractCompletion } = await import('@/lib/onboarding-completion'); + // Scan assistant messages from newest to oldest for an unprocessed completion + for (let i = initialMessages.length - 1; i >= 0; i--) { + const msg = initialMessages[i]; + if (msg.role !== 'assistant') continue; + const completion = extractCompletion(msg.content); + if (completion?.type === 'onboarding') { + console.log('[ChatView] Found unprocessed onboarding completion in message history, compensating...'); + const resp = await fetch('/api/workspace/onboarding', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ answers: completion.answers, sessionId }), + }); + if (resp.ok) { + await fetch('/api/workspace/hook-triggered', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId: '__clear__' }), + }).catch(() => {}); + console.log('[ChatView] Onboarding compensation succeeded, skipping re-trigger'); + return; // Don't re-trigger onboarding + } + break; // Found fence but processing failed — fall through to re-trigger + } + } + } catch (e) { + console.error('[ChatView] Onboarding compensation check failed:', e); + } + } + // For daily check-in, only trigger in the most recent session for this workspace. // This prevents older sessions from hijacking the check-in when reopened. if (needsCheckIn) { @@ -317,6 +354,10 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal sendMessageFn: (retryContent: string, retryFiles?: FileAttachment[]) => { sendMessageRef.current?.(retryContent, retryFiles); }, + onInitMeta: (meta) => { + initMetaRef.current = meta; + console.log('[ChatView] SDK init meta received:', meta); + }, }); } catch (e) { console.error('[ChatView] Assistant auto-trigger failed:', e); @@ -433,69 +474,6 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal [sessionId, setPendingApprovalSessionId] ); - // Detect assistant project completion signals in final message content. - // Only processes fences when this session's workingDirectory matches the current assistant workspace path. - const detectAssistantCompletion = useCallback(async (content: string) => { - // Guard: only allow fence processing for the current assistant workspace session - if (!workingDirectory) return; - try { - const wsRes = await fetch('/api/settings/workspace'); - if (!wsRes.ok) return; - const wsData = await wsRes.json(); - if (!wsData.path || wsData.path !== workingDirectory) return; - } catch { - return; - } - - const clearHookTriggered = () => - fetch('/api/workspace/hook-triggered', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionId: '__clear__' }), - }).catch(() => {}); - - // Check for onboarding completion - const onboardingMatch = content.match(/```onboarding-complete\s*\r?\n([\s\S]*?)\r?\n\s*```/); - if (onboardingMatch) { - try { - const answers = JSON.parse(onboardingMatch[1].trim()); - const resp = await fetch('/api/workspace/onboarding', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ answers, sessionId }), - }); - if (resp.ok) { - await clearHookTriggered(); - } else { - console.error('[ChatView] Onboarding API returned', resp.status); - } - } catch (e) { - console.error('[ChatView] Onboarding completion failed:', e); - } - return; - } - - // Check for check-in completion - const checkinMatch = content.match(/```checkin-complete\s*\r?\n([\s\S]*?)\r?\n\s*```/); - if (checkinMatch) { - try { - const answers = JSON.parse(checkinMatch[1].trim()); - const resp = await fetch('/api/workspace/checkin', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ answers, sessionId }), - }); - if (resp.ok) { - await clearHookTriggered(); - } else { - console.error('[ChatView] Check-in API returned', resp.status); - } - } catch (e) { - console.error('[ChatView] Check-in completion failed:', e); - } - } - }, [sessionId, workingDirectory]); - // Build SDK thinking config from settings const buildThinkingConfig = useCallback((): { type: string } | undefined => { if (!thinkingMode || thinkingMode === 'adaptive') return { type: 'adaptive' }; @@ -550,6 +528,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal pendingImageNotices: notices, effort: selectedEffort, thinking: buildThinkingConfig(), + displayOverride, onModeChanged: (sdkMode) => { const uiMode = sdkMode === 'plan' ? 'plan' : 'code'; handleModeChange(uiMode); @@ -557,6 +536,10 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal sendMessageFn: (retryContent: string, retryFiles?: FileAttachment[]) => { sendMessageRef.current?.(retryContent, retryFiles); }, + onInitMeta: (meta) => { + initMetaRef.current = meta; + console.log('[ChatView] SDK init meta received:', meta); + }, }); }, [sessionId, isStreaming, mode, currentModel, currentProviderId, selectedEffort, buildThinkingConfig, handleModeChange] @@ -734,6 +717,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal onAssistantTrigger={checkAssistantTrigger} effort={selectedEffort} onEffortChange={setSelectedEffort} + sdkInitMeta={initMetaRef.current} /> } diff --git a/src/components/chat/MessageInput.tsx b/src/components/chat/MessageInput.tsx index a78dc7d..badad4e 100644 --- a/src/components/chat/MessageInput.tsx +++ b/src/components/chat/MessageInput.tsx @@ -33,7 +33,7 @@ import { usePromptInputAttachments, } from '@/components/ai-elements/prompt-input'; import type { ChatStatus } from 'ai'; -import type { FileAttachment, ProviderModelGroup } from '@/types'; +import type { FileAttachment, ProviderModelGroup, SkillKind } from '@/types'; import { nanoid } from 'nanoid'; import { SlashCommandButton } from './SlashCommandButton'; import { useImageGen } from '@/hooks/useImageGen'; @@ -101,6 +101,8 @@ interface MessageInputProps { /** Effort selection lifted to parent for inclusion in the stream chain */ effort?: string; onEffortChange?: (effort: string | undefined) => void; + /** SDK init metadata — when available, used to validate command/skill availability */ + sdkInitMeta?: { tools?: unknown; slash_commands?: unknown; skills?: unknown } | null; } interface PopoverItem { @@ -111,7 +113,8 @@ interface PopoverItem { builtIn?: boolean; immediate?: boolean; installedSource?: "agents" | "claude"; - source?: "global" | "project" | "plugin" | "installed"; + source?: "global" | "project" | "plugin" | "installed" | "sdk"; + kind?: SkillKind; icon?: typeof CommandLineIcon; } @@ -119,7 +122,7 @@ interface CommandBadge { command: string; label: string; description: string; - isSkill: boolean; + kind: SkillKind; installedSource?: "agents" | "claude"; } @@ -137,12 +140,12 @@ const BUILT_IN_COMMANDS: PopoverItem[] = [ { label: 'help', value: '/help', description: 'Show available commands and tips', descriptionKey: 'messageInput.helpDesc', builtIn: true, immediate: true, icon: HelpCircleIcon }, { label: 'clear', value: '/clear', description: 'Clear conversation history', descriptionKey: 'messageInput.clearDesc', builtIn: true, immediate: true, icon: Delete02Icon }, { label: 'cost', value: '/cost', description: 'Show token usage statistics', descriptionKey: 'messageInput.costDesc', builtIn: true, immediate: true, icon: Coins01Icon }, - { label: 'compact', value: '/compact', description: 'Compress conversation context', descriptionKey: 'messageInput.compactDesc', builtIn: true, icon: FileZipIcon }, - { label: 'doctor', value: '/doctor', description: 'Diagnose project health', descriptionKey: 'messageInput.doctorDesc', builtIn: true, icon: Stethoscope02Icon }, - { label: 'init', value: '/init', description: 'Initialize CLAUDE.md for project', descriptionKey: 'messageInput.initDesc', builtIn: true, icon: FileEditIcon }, - { label: 'review', value: '/review', description: 'Review code quality', descriptionKey: 'messageInput.reviewDesc', builtIn: true, icon: SearchList01Icon }, - { label: 'terminal-setup', value: '/terminal-setup', description: 'Configure terminal settings', descriptionKey: 'messageInput.terminalSetupDesc', builtIn: true, icon: CommandLineIcon }, - { label: 'memory', value: '/memory', description: 'Edit project memory file', descriptionKey: 'messageInput.memoryDesc', builtIn: true, icon: BrainIcon }, + { label: 'compact', value: '/compact', description: 'Compress conversation context', descriptionKey: 'messageInput.compactDesc', builtIn: true, kind: 'sdk_command', icon: FileZipIcon }, + { label: 'doctor', value: '/doctor', description: 'Diagnose project health', descriptionKey: 'messageInput.doctorDesc', builtIn: true, kind: 'codepilot_command', icon: Stethoscope02Icon }, + { label: 'init', value: '/init', description: 'Initialize CLAUDE.md for project', descriptionKey: 'messageInput.initDesc', builtIn: true, kind: 'sdk_command', icon: FileEditIcon }, + { label: 'review', value: '/review', description: 'Review code quality', descriptionKey: 'messageInput.reviewDesc', builtIn: true, kind: 'sdk_command', icon: SearchList01Icon }, + { label: 'terminal-setup', value: '/terminal-setup', description: 'Configure terminal settings', descriptionKey: 'messageInput.terminalSetupDesc', builtIn: true, kind: 'codepilot_command', icon: CommandLineIcon }, + { label: 'memory', value: '/memory', description: 'Edit project memory file', descriptionKey: 'messageInput.memoryDesc', builtIn: true, kind: 'codepilot_command', icon: BrainIcon }, ]; interface ModeOption { @@ -380,6 +383,7 @@ export function MessageInput({ onAssistantTrigger, effort: effortProp, onEffortChange, + sdkInitMeta, }: MessageInputProps) { const { t } = useTranslation(); const imageGen = useImageGen(); @@ -489,25 +493,78 @@ export function MessageInput({ const data = await res.json(); const skills = data.skills || []; apiSkills = skills - .map((s: { name: string; description: string; source?: "global" | "project" | "plugin" | "installed"; installedSource?: "agents" | "claude" }) => ({ + .map((s: { name: string; description: string; source?: "global" | "project" | "plugin" | "installed" | "sdk"; kind?: SkillKind; installedSource?: "agents" | "claude" }) => ({ label: s.name, value: `/${s.name}`, description: s.description || "", builtIn: false, installedSource: s.installedSource, source: s.source, + kind: s.kind || 'slash_command', })); } } catch { // API not available - just use built-in commands } + // When SDK init metadata is available, use it as the truth source: + // 1. Filter out filesystem-scanned items that the SDK session didn't actually load + // 2. Add any SDK-reported commands/skills missing from the filesystem scan + // Note: SDK system:init reports slash_commands and skills as string[] (names only) + if (sdkInitMeta) { + const rawCmds = sdkInitMeta.slash_commands; + const rawSkills = sdkInitMeta.skills; + const sdkCommandNames = new Set( + Array.isArray(rawCmds) ? rawCmds.map(c => typeof c === 'string' ? c : (c as { name?: string })?.name).filter(Boolean) as string[] : [] + ); + const sdkSkillNames = new Set( + Array.isArray(rawSkills) ? rawSkills.map(s => typeof s === 'string' ? s : (s as { name?: string })?.name).filter(Boolean) as string[] : [] + ); + + // Only filter if SDK actually reported capabilities (non-empty arrays) + if (sdkCommandNames.size > 0 || sdkSkillNames.size > 0) { + apiSkills = apiSkills.filter(item => { + if (item.kind === 'agent_skill') return sdkSkillNames.has(item.label); + return sdkCommandNames.has(item.label); + }); + } + + const existingNames = new Set(apiSkills.map(s => s.label)); + + // Add SDK-reported commands not found in filesystem scan + for (const cmdName of sdkCommandNames) { + if (!existingNames.has(cmdName)) { + apiSkills.push({ + label: cmdName, + value: `/${cmdName}`, + description: `SDK command: /${cmdName}`, + builtIn: false, + source: 'sdk', + kind: 'sdk_command', + }); + } + } + + // Add SDK-reported skills not found in filesystem scan + for (const skillName of sdkSkillNames) { + if (!existingNames.has(skillName)) { + apiSkills.push({ + label: skillName, + value: `/${skillName}`, + description: `Skill: /${skillName}`, + builtIn: false, + kind: 'agent_skill', + }); + } + } + } + // Deduplicate: remove API skills that share a name with built-in commands const builtInNames = new Set(BUILT_IN_COMMANDS.map(c => c.label)); const uniqueSkills = apiSkills.filter(s => !builtInNames.has(s.label)); return [...BUILT_IN_COMMANDS, ...uniqueSkills]; - }, [workingDirectory]); + }, [workingDirectory, sdkInitMeta]); // Close popover const closePopover = useCallback(() => { @@ -553,7 +610,7 @@ export function MessageInput({ command: item.value, label: item.label, description: item.description || '', - isSkill: !item.builtIn, + kind: item.kind || 'slash_command', installedSource: item.installedSource, }); setInputValue(''); @@ -682,41 +739,49 @@ export function MessageInput({ return; } - // If badge is active, expand the command/skill and send + // If badge is active, dispatch by kind if (badge && !isStreaming) { - let expandedPrompt = ''; - - if (badge.isSkill) { - // Fetch skill content from API - try { - const detailParams = new URLSearchParams(); - if (badge.installedSource) detailParams.set("source", badge.installedSource); - if (workingDirectory) detailParams.set("cwd", workingDirectory); - const qs = detailParams.toString(); - const res = await fetch( - `/api/skills/${encodeURIComponent(badge.label)}${qs ? `?${qs}` : ""}` - ); - if (res.ok) { - const data = await res.json(); - expandedPrompt = data.skill?.content || ''; - } - } catch { - // Fallback: use command name - } - } else { - // Built-in prompt command expansion - expandedPrompt = COMMAND_PROMPTS[badge.command] || ''; - } - - const finalPrompt = content - ? `${expandedPrompt}\n\nUser context: ${content}` - : expandedPrompt || badge.command; - const files = await convertFiles(); - setBadge(null); - setInputValue(''); - onSend(finalPrompt, files.length > 0 ? files : undefined); - return; + const displayLabel = `/${badge.label}`; + + switch (badge.kind) { + case 'agent_skill': { + // Agent skills: SDK loads SKILL.md natively from filesystem. + // Always include skill name so Claude knows which skill was selected. + // User context is appended after the skill reference. + const agentPrompt = content + ? `Use the ${badge.label} skill. User context: ${content}` + : `Please use the ${badge.label} skill.`; + setBadge(null); + setInputValue(''); + onSend(agentPrompt, files.length > 0 ? files : undefined, undefined, displayLabel); + return; + } + + case 'slash_command': + case 'sdk_command': { + // Slash commands & SDK commands: send "/{command} {context}" as-is for SDK to handle + const slashPrompt = content + ? `${badge.command} ${content}` + : badge.command; + setBadge(null); + setInputValue(''); + onSend(slashPrompt, files.length > 0 ? files : undefined, undefined, displayLabel); + return; + } + + case 'codepilot_command': { + // CodePilot-specific commands: expand via COMMAND_PROMPTS, show /command in bubble + const expandedPrompt = COMMAND_PROMPTS[badge.command] || ''; + const finalPrompt = content + ? `${expandedPrompt}\n\nUser context: ${content}` + : expandedPrompt || badge.command; + setBadge(null); + setInputValue(''); + onSend(finalPrompt, files.length > 0 ? files : undefined, undefined, displayLabel); + return; + } + } } const files = await convertFiles(); @@ -738,20 +803,20 @@ export function MessageInput({ command: cmd.value, label: cmd.label, description: cmd.description || '', - isSkill: false, + kind: cmd.kind || 'sdk_command', }); setInputValue(''); return; } - // Not a built-in command — treat as a skill + // Not a built-in command — default to slash_command (SDK will handle) const skillName = content.slice(1); if (skillName) { setBadge({ command: content, label: skillName, description: '', - isSkill: true, + kind: 'slash_command', }); setInputValue(''); return; @@ -972,8 +1037,8 @@ export function MessageInput({ {/* Popover */} {popoverMode && (allDisplayedItems.length > 0 || aiSearchLoading) && (() => { const builtInItems = filteredItems.filter(item => item.builtIn); - const projectItems = filteredItems.filter(item => !item.builtIn && item.source === 'project'); - const skillItems = filteredItems.filter(item => !item.builtIn && item.source !== 'project'); + const slashCommandItems = filteredItems.filter(item => !item.builtIn && item.kind !== 'agent_skill'); + const agentSkillItems = filteredItems.filter(item => !item.builtIn && item.kind === 'agent_skill'); let globalIdx = 0; const renderItem = (item: PopoverItem, idx: number) => ( @@ -991,7 +1056,9 @@ export function MessageInput({ ) : item.builtIn && item.icon ? ( - ) : !item.builtIn && item.source === 'project' ? ( + ) : item.kind === 'agent_skill' ? ( + + ) : item.kind === 'slash_command' ? ( ) : !item.builtIn ? ( @@ -1077,23 +1144,23 @@ export function MessageInput({ })} )} - {projectItems.length > 0 && ( + {slashCommandItems.length > 0 && ( <>
- Project Commands + Slash Commands
- {projectItems.map((item) => { + {slashCommandItems.map((item) => { const idx = globalIdx++; return renderItem(item, idx); })} )} - {skillItems.length > 0 && ( + {agentSkillItems.length > 0 && ( <>
- Skills + Agent Skills
- {skillItems.map((item) => { + {agentSkillItems.map((item) => { const idx = globalIdx++; return renderItem(item, idx); })} diff --git a/src/components/plugins/McpManager.tsx b/src/components/plugins/McpManager.tsx index b4838b3..4f9c3d1 100644 --- a/src/components/plugins/McpManager.tsx +++ b/src/components/plugins/McpManager.tsx @@ -19,9 +19,11 @@ interface McpRuntimeStatus { serverInfo?: { name: string; version: string }; } +type MCPServerWithSource = MCPServer & { _source?: string }; + export function McpManager() { const { t } = useTranslation(); - const [servers, setServers] = useState>({}); + const [servers, setServers] = useState>({}); const [loading, setLoading] = useState(true); const [editorOpen, setEditorOpen] = useState(false); const [editingName, setEditingName] = useState(); @@ -116,9 +118,11 @@ export function McpManager() { async function handleSave(name: string, server: MCPServer) { if (editingName && editingName !== name) { + // Rename: preserve _source from the original entry + const original = servers[editingName]; const updated = { ...servers }; delete updated[editingName]; - updated[name] = server; + updated[name] = original?._source ? { ...server, _source: original._source } : server; try { await fetch("/api/plugins/mcp", { method: "PUT", @@ -130,7 +134,10 @@ export function McpManager() { console.error("Failed to save MCP server:", err); } } else if (editingName) { - const updated = { ...servers, [name]: server }; + // Edit in-place: preserve _source + const original = servers[editingName]; + const serverWithSource = original?._source ? { ...server, _source: original._source } : server; + const updated = { ...servers, [name]: serverWithSource }; try { await fetch("/api/plugins/mcp", { method: "PUT", @@ -162,13 +169,26 @@ export function McpManager() { async function handleJsonSave(jsonStr: string) { try { - const parsed = JSON.parse(jsonStr); + const parsed = JSON.parse(jsonStr) as Record; + // JSON editor only manages settings.json servers. + // Merge back: keep claude.json servers untouched, replace settings.json servers. + const claudeJsonServers: Record = {}; + for (const [name, server] of Object.entries(servers)) { + if (server._source === 'claude.json') { + claudeJsonServers[name] = server; + } + } + const settingsServers: Record = {}; + for (const [name, server] of Object.entries(parsed)) { + settingsServers[name] = { ...server, _source: 'settings.json' }; + } + const merged = { ...claudeJsonServers, ...settingsServers }; await fetch("/api/plugins/mcp", { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ mcpServers: parsed }), + body: JSON.stringify({ mcpServers: merged }), }); - setServers(parsed); + setServers(merged); } catch (err) { console.error("Failed to save MCP config:", err); } @@ -234,8 +254,25 @@ export function McpManager() { + {Object.values(servers).some(s => s._source === 'claude.json') && ( +

+ Servers from ~/.claude.json are managed by Claude CLI and not shown here. + Use the list tab to edit or delete them. +

+ )} v._source !== 'claude.json') + .map(([k, v]) => { + const { _source, ...rest } = v; + return [k, rest]; + }) + ), + null, + 2, + )} onSave={handleJsonSave} label={t('mcp.serverConfig')} /> diff --git a/src/components/plugins/McpServerEditor.tsx b/src/components/plugins/McpServerEditor.tsx index e015099..3ea7f12 100644 --- a/src/components/plugins/McpServerEditor.tsx +++ b/src/components/plugins/McpServerEditor.tsx @@ -162,7 +162,6 @@ export function McpServerEditor({ ...(env ? { env } : {}), } : { - command: '', type: serverType, ...(url ? { url: url.trim() } : {}), ...(serverArgs.length > 0 ? { args: serverArgs } : {}), diff --git a/src/hooks/useSSEStream.ts b/src/hooks/useSSEStream.ts index 7c42af8..13c7097 100644 --- a/src/hooks/useSSEStream.ts +++ b/src/hooks/useSSEStream.ts @@ -27,6 +27,7 @@ export interface SSECallbacks { onRewindPoint: (sdkUserMessageId: string) => void; onKeepAlive: () => void; onError: (accumulated: string) => void; + onInitMeta?: (meta: { tools?: unknown; slash_commands?: unknown; skills?: unknown }) => void; } /** @@ -91,6 +92,11 @@ function handleSSEEvent( const statusData = JSON.parse(event.data); if (statusData.session_id) { callbacks.onStatus(`Connected (${statusData.requested_model || statusData.model || 'claude'})`); + callbacks.onInitMeta?.({ + tools: statusData.tools, + slash_commands: statusData.slash_commands, + skills: statusData.skills, + }); } else if (statusData.notification) { callbacks.onStatus(statusData.message || statusData.title || undefined); } else { @@ -254,6 +260,7 @@ export function useSSEStream() { onRewindPoint: (id) => callbacksRef.current?.onRewindPoint(id), onKeepAlive: () => callbacksRef.current?.onKeepAlive(), onError: (a) => callbacksRef.current?.onError(a), + onInitMeta: (m) => callbacksRef.current?.onInitMeta?.(m), }; return consumeSSEStream(reader, proxied); diff --git a/src/lib/checkin-processor.ts b/src/lib/checkin-processor.ts new file mode 100644 index 0000000..0dee1ea --- /dev/null +++ b/src/lib/checkin-processor.ts @@ -0,0 +1,168 @@ +/** + * Core check-in processing logic, extracted from the API route + * so it can be called directly from server-side completion detection + * without an HTTP round-trip. + */ +import fs from 'fs'; +import path from 'path'; +import { getSetting, getSession } from '@/lib/db'; +import { resolveProvider } from '@/lib/provider-resolver'; +import { loadState, saveState, writeDailyMemory } from '@/lib/assistant-workspace'; +import { getLocalDateString } from '@/lib/utils'; +import { generateTextFromProvider } from '@/lib/text-generator'; + +const CHECK_IN_LABELS = [ + 'What did you work on or accomplish today?', + 'Any changes to your current priorities or goals?', + 'Anything you\'d like me to remember going forward?', +]; + +/** + * Process check-in completion. Generates daily memory, promotes stable facts, + * updates user profile. + * + * Idempotent for today: if state.lastCheckInDate is already today, returns early. + * + * @throws Error if workspace path is not configured or processing fails + */ +export async function processCheckin( + answers: Record, + sessionId?: string, +): Promise { + const workspacePath = getSetting('assistant_workspace_path'); + if (!workspacePath) { + throw new Error('No workspace path configured'); + } + + const today = getLocalDateString(); + + // Idempotent check: skip if already checked in today + const currentState = loadState(workspacePath); + if (currentState.lastCheckInDate === today) { + return; + } + + // Look up the calling session for provider/model context + let session: ReturnType | undefined; + if (sessionId) { + session = getSession(sessionId) ?? undefined; + if (session && session.working_directory !== workspacePath) { + throw new Error('Session does not belong to current workspace'); + } + } + + const qaText = CHECK_IN_LABELS.map((q, i) => { + const key = `q${i + 1}`; + return `Q: ${q}\nA: ${answers[key] || '(skipped)'}`; + }).join('\n\n'); + + // Read existing files for context + const memoryPath = path.join(workspacePath, 'memory.md'); + const userPath = path.join(workspacePath, 'user.md'); + let existingMemory = ''; + let existingUser = ''; + try { existingMemory = fs.readFileSync(memoryPath, 'utf-8'); } catch { /* new file */ } + try { existingUser = fs.readFileSync(userPath, 'utf-8'); } catch { /* new file */ } + + try { + const resolved = resolveProvider({ + sessionProviderId: session?.provider_id || undefined, + sessionModel: session?.model || undefined, + }); + const providerId = resolved.provider?.id || 'env'; + const model = resolved.upstreamModel || resolved.model || getSetting('default_model') || 'claude-sonnet-4-20250514'; + + const dailyMemoryPrompt = `You maintain daily memory entries for an AI assistant. Given the user's daily check-in answers, generate a daily memory entry for ${today}. + +Format it with these sections: +## Work Log +(What the user accomplished today) + +## Priority Changes +(Any shifts in goals or priorities) + +## To Remember +(Things the user wants remembered) + +## Candidate Long-Term Memory +(Facts that seem stable enough to promote to long-term memory — only include genuinely persistent facts, not transient updates) + +Keep under 2000 characters. + +Today's check-in (${today}): +${qaText}`; + + const promotionPrompt = `You maintain a long-term memory file for an AI assistant. Given the user's check-in and the existing memory, output ONLY new stable facts that should be APPENDED to memory.md. These must be genuinely persistent facts (user preferences, recurring patterns, important relationships), NOT daily transients. + +If there's nothing worth promoting, output exactly: (nothing to promote) + +Existing memory.md: +${existingMemory || '(empty)'} + +Today's check-in (${today}): +${qaText}`; + + const userPrompt = `You maintain a user.md profile for an AI assistant. Given the user's daily check-in answers and the existing profile, generate an UPDATED user.md. Only update sections affected by today's answers. Keep it organized with markdown headers. Keep under 2000 characters. + +Existing user.md: +${existingUser || '(empty)'} + +Today's check-in (${today}): +${qaText}`; + + const [dailyContent, promotionContent, newUser] = await Promise.all([ + generateTextFromProvider({ providerId, model, system: 'You maintain knowledge files for AI assistants. Output only the file content, no explanations.', prompt: dailyMemoryPrompt }), + generateTextFromProvider({ providerId, model, system: 'You maintain knowledge files for AI assistants. Output only the content to append, no explanations.', prompt: promotionPrompt }), + generateTextFromProvider({ providerId, model, system: 'You maintain user profile documents. Output only the file content, no explanations.', prompt: userPrompt }), + ]); + + // Write daily memory file (episodic, per-day) + if (dailyContent.trim()) { + writeDailyMemory(workspacePath, today, dailyContent); + } + + // Promote stable facts to memory.md (additive append, NOT rewrite) + // Dedup: skip if today's promotion already exists in memory.md + if (promotionContent.trim() && !promotionContent.includes('(nothing to promote)')) { + const currentMemory = fs.existsSync(memoryPath) ? fs.readFileSync(memoryPath, 'utf-8') : ''; + if (!currentMemory.includes(`## Promoted from ${today}`)) { + const appendText = `\n\n## Promoted from ${today}\n${promotionContent.trim()}\n`; + fs.appendFileSync(memoryPath, appendText, 'utf-8'); + } + } + + // Incremental user.md update + if (existingUser && newUser.trim()) { + fs.writeFileSync(userPath, newUser, 'utf-8'); + } + + // Archive old daily memories and promote candidates + try { + const { archiveDailyMemories, promoteDailyToLongTerm } = await import('@/lib/workspace-organizer'); + archiveDailyMemories(workspacePath); + + const sevenDaysAgo = new Date(); + sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); + const dailyDir = path.join(workspacePath, 'memory', 'daily'); + if (fs.existsSync(dailyDir)) { + const oldFiles = fs.readdirSync(dailyDir) + .filter(f => /^\d{4}-\d{2}-\d{2}\.md$/.test(f)) + .filter(f => f.replace('.md', '') <= getLocalDateString(sevenDaysAgo)); + for (const f of oldFiles) { + promoteDailyToLongTerm(workspacePath, f.replace('.md', '')); + } + } + } catch { + // organizer module not available, skip archival + } + } catch (e) { + console.warn('[checkin-processor] AI generation failed, writing raw daily entry:', e); + const rawDailyContent = `# Daily Check-in ${today}\n\n${qaText}\n`; + writeDailyMemory(workspacePath, today, rawDailyContent); + } + + // Update state + const state = loadState(workspacePath); + state.lastCheckInDate = today; + saveState(workspacePath, state); +} diff --git a/src/lib/claude-client.ts b/src/lib/claude-client.ts index bdbb7c5..a893942 100644 --- a/src/lib/claude-client.ts +++ b/src/lib/claude-client.ts @@ -798,6 +798,7 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream | null { + const trimmed = raw.trim(); + + // Pre-clean: strip markdown formatting (**bold**, *italic*) from values + // This is safe because * is not valid unquoted JSON syntax + let cleaned = trimmed.replace(/\*{1,2}([^*]+)\*{1,2}/g, '$1'); + + // Strategy 1: direct parse + try { + const parsed = JSON.parse(cleaned); + if (isValidAnswerMap(parsed)) return normalizeAnswerMap(parsed); + } catch { /* continue */ } + + // Strategy 3: fix unescaped newlines inside JSON strings + // Replace literal newlines between quotes with \\n + cleaned = cleaned.replace(/"([^"]*?)"/g, (_match, inner: string) => { + return '"' + inner.replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t') + '"'; + }); + + try { + const parsed = JSON.parse(cleaned); + if (isValidAnswerMap(parsed)) return normalizeAnswerMap(parsed); + } catch { /* continue */ } + + // Strategy 4: single quotes → double quotes (careful: only structural quotes) + cleaned = cleaned.replace(/'/g, '"'); + + try { + const parsed = JSON.parse(cleaned); + if (isValidAnswerMap(parsed)) return normalizeAnswerMap(parsed); + } catch { /* continue */ } + + // Strategy 5: remove trailing commas before } or ] + cleaned = cleaned.replace(/,\s*([}\]])/g, '$1'); + + try { + const parsed = JSON.parse(cleaned); + if (isValidAnswerMap(parsed)) return normalizeAnswerMap(parsed); + } catch { /* continue */ } + + // Strategy 6: regex extraction as last resort + // Match patterns like "q1": "some value" or "q1":"some value" + const result: Record = {}; + const kvRegex = /"(q\d{1,2})"\s*:\s*"((?:[^"\\]|\\.)*)"/g; + let m: RegExpExecArray | null; + while ((m = kvRegex.exec(trimmed)) !== null) { + result[m[1]] = m[2].replace(/\\"/g, '"').replace(/\\n/g, '\n').replace(/\\r/g, ''); + } + if (Object.keys(result).length >= 3) { + // Accept if we got at least 3 answers (partial is better than nothing) + return result; + } + + return null; +} + +function isValidAnswerMap(obj: unknown): obj is Record { + return typeof obj === 'object' && obj !== null && !Array.isArray(obj); +} + +function normalizeAnswerMap(obj: Record): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + result[key] = typeof value === 'string' ? value : String(value ?? ''); + } + return result; +} + +// ─── High-Level Helpers ───────────────────────────────────────────── + +export interface ExtractedCompletion { + type: 'onboarding' | 'checkin'; + answers: Record; +} + +/** + * Extract and parse a completion from assistant message content. + * Returns null if no valid completion found. + */ +export function extractCompletion(content: string): ExtractedCompletion | null { + const fence = extractCompletionFence(content); + if (!fence) return null; + + const answers = parseCompletionPayload(fence.rawPayload); + if (!answers) return null; + + return { type: fence.type, answers }; +} diff --git a/src/lib/onboarding-processor.ts b/src/lib/onboarding-processor.ts new file mode 100644 index 0000000..5dfb81c --- /dev/null +++ b/src/lib/onboarding-processor.ts @@ -0,0 +1,176 @@ +/** + * Core onboarding processing logic, extracted from the API route + * so it can be called directly from server-side completion detection + * without an HTTP round-trip. + */ +import fs from 'fs'; +import path from 'path'; +import { getSetting, getSession } from '@/lib/db'; +import { resolveProvider } from '@/lib/provider-resolver'; +import { loadState, saveState, ensureDailyDir, generateRootDocs } from '@/lib/assistant-workspace'; +import { getLocalDateString } from '@/lib/utils'; +import { generateTextFromProvider } from '@/lib/text-generator'; + +const QUESTION_LABELS = [ + 'How should I address you?', + 'What name should I use for myself?', + 'Do you prefer "concise and direct" or "detailed explanations"?', + 'Do you prefer "minimal interruptions" or "proactive suggestions"?', + 'What are your three hard boundaries?', + 'What are your three most important current goals?', + 'Do you prefer output as "lists", "reports", or "conversation summaries"?', + 'What information may be written to long-term memory?', + 'What information must never be written to long-term memory?', + 'What three things should I do first when entering a project?', + 'How do you organize your materials? (by project / time / topic / mixed)', + 'Where should new information go by default?', + 'How should completed tasks be archived?', +]; + +/** + * Process onboarding completion. Generates workspace files from answers. + * Idempotent: if state.onboardingComplete is already true, returns early. + * + * @throws Error if workspace path is not configured or processing fails + */ +export async function processOnboarding( + answers: Record, + sessionId?: string, +): Promise { + const workspacePath = getSetting('assistant_workspace_path'); + if (!workspacePath) { + throw new Error('No workspace path configured'); + } + + // Idempotent check + const currentState = loadState(workspacePath); + if (currentState.onboardingComplete) { + return; + } + + // Look up the calling session for provider/model context + let session: ReturnType | undefined; + if (sessionId) { + session = getSession(sessionId) ?? undefined; + if (session && session.working_directory !== workspacePath) { + throw new Error('Session does not belong to current workspace'); + } + } + + // Build Q&A text for the prompt + const qaText = QUESTION_LABELS.map((q, i) => { + const key = `q${i + 1}`; + return `Q: ${q}\nA: ${answers[key] || '(skipped)'}`; + }).join('\n\n'); + + let soulContent: string; + let userContent: string; + let claudeContent: string; + let memoryContent: string; + + try { + const resolved = resolveProvider({ + sessionProviderId: session?.provider_id || undefined, + sessionModel: session?.model || undefined, + }); + const providerId = resolved.provider?.id || 'env'; + const model = resolved.upstreamModel || resolved.model || getSetting('default_model') || 'claude-sonnet-4-20250514'; + + const soulPrompt = `Based on the following user onboarding answers, generate a concise "soul.md" file that defines an AI assistant's personality, communication style, and behavioral rules. Write in second person ("You are..."). Keep it under 2000 characters. Use markdown headers and bullet points.\n\n${qaText}`; + + const userPrompt = `Based on the following user onboarding answers, generate a concise "user.md" profile that captures the user's preferences, goals, and boundaries. Write in third person. Keep it under 2000 characters. Use markdown headers and bullet points.\n\n${qaText}`; + + const claudePrompt = `Based on the following user onboarding answers, generate a "claude.md" rules file for an AI assistant. Include: +- Execution rules (what to do when entering a project, based on Q10) +- Communication style rules (based on Q3, Q4, Q7) +- Memory rules (what to remember/forget, based on Q8, Q9) +- Hard boundaries (based on Q5) +Keep it under 2000 characters. Use markdown headers and bullet points.\n\n${qaText}`; + + const memoryPrompt = `Based on the following user onboarding answers, generate an initial "memory.md" file with long-term facts about the user worth remembering. Include user goals, preferences, and any stable facts. Keep it under 1000 characters. Use markdown headers.\n\n${qaText}`; + + [soulContent, userContent, claudeContent, memoryContent] = await Promise.all([ + generateTextFromProvider({ providerId, model, system: 'You generate configuration files for AI assistants. Output only the file content, no explanations.', prompt: soulPrompt }), + generateTextFromProvider({ providerId, model, system: 'You generate user profile documents. Output only the file content, no explanations.', prompt: userPrompt }), + generateTextFromProvider({ providerId, model, system: 'You generate configuration files for AI assistants. Output only the file content, no explanations.', prompt: claudePrompt }), + generateTextFromProvider({ providerId, model, system: 'You generate knowledge files for AI assistants. Output only the file content, no explanations.', prompt: memoryPrompt }), + ]); + + if (!soulContent.trim() || !userContent.trim()) { + throw new Error('AI returned empty content'); + } + } catch (e) { + console.warn('[onboarding-processor] AI generation failed, using raw answers:', e); + soulContent = `# Soul\n\n## Communication Style\n- Address user as: ${answers.q1 || 'not specified'}\n- Assistant name: ${answers.q2 || 'not specified'}\n- Style: ${answers.q3 || 'not specified'}\n- Approach: ${answers.q4 || 'not specified'}\n`; + userContent = `# User Profile\n\n## Preferences\n- Boundaries: ${answers.q5 || 'not specified'}\n- Goals: ${answers.q6 || 'not specified'}\n- Output format: ${answers.q7 || 'not specified'}\n- Memory allowed: ${answers.q8 || 'not specified'}\n- Memory forbidden: ${answers.q9 || 'not specified'}\n- Project entry: ${answers.q10 || 'not specified'}\n- Organization: ${answers.q11 || 'not specified'}\n- Default capture: ${answers.q12 || 'not specified'}\n- Archive policy: ${answers.q13 || 'not specified'}\n`; + claudeContent = `# Rules\n\n## Execution\n- On project entry: ${answers.q10 || 'not specified'}\n\n## Boundaries\n- ${answers.q5 || 'not specified'}\n\n## Memory\n- Allowed: ${answers.q8 || 'not specified'}\n- Forbidden: ${answers.q9 || 'not specified'}\n`; + memoryContent = `# Memory\n\n## User Goals\n- ${answers.q6 || 'not specified'}\n`; + } + + // Write all core files + fs.writeFileSync(path.join(workspacePath, 'soul.md'), soulContent, 'utf-8'); + fs.writeFileSync(path.join(workspacePath, 'user.md'), userContent, 'utf-8'); + if (claudeContent.trim()) { + fs.writeFileSync(path.join(workspacePath, 'claude.md'), claudeContent, 'utf-8'); + } + if (memoryContent.trim()) { + fs.writeFileSync(path.join(workspacePath, 'memory.md'), memoryContent, 'utf-8'); + } + + // Ensure V2 directories + ensureDailyDir(workspacePath); + const inboxDir = path.join(workspacePath, 'Inbox'); + if (!fs.existsSync(inboxDir)) { + fs.mkdirSync(inboxDir, { recursive: true }); + } + + // Generate config.json from answers + try { + const { loadConfig, saveConfig } = await import('@/lib/workspace-config'); + const config = loadConfig(workspacePath); + + const orgStyle = (answers.q11 || '').toLowerCase(); + if (orgStyle.includes('project')) config.organizationStyle = 'project'; + else if (orgStyle.includes('time')) config.organizationStyle = 'time'; + else if (orgStyle.includes('topic')) config.organizationStyle = 'topic'; + else config.organizationStyle = 'mixed'; + + if (answers.q12) { + let capture = answers.q12.trim(); + if (path.isAbsolute(capture) || capture.startsWith('~') || capture.includes('..')) { + capture = 'Inbox'; + } + config.captureDefault = capture; + } + + saveConfig(workspacePath, config); + } catch { + // config module not available, skip + } + + // Generate taxonomy from existing directories + try { + const { loadTaxonomy, saveTaxonomy, inferTaxonomyFromDirs } = await import('@/lib/workspace-taxonomy'); + const taxonomy = loadTaxonomy(workspacePath); + if (taxonomy.categories.length === 0) { + const inferred = inferTaxonomyFromDirs(workspacePath); + if (inferred.length > 0) { + taxonomy.categories = inferred; + saveTaxonomy(workspacePath, taxonomy); + } + } + } catch { + // taxonomy module not available, skip + } + + // Generate root docs + generateRootDocs(workspacePath); + + // Update state + const today = getLocalDateString(); + const state = loadState(workspacePath); + state.onboardingComplete = true; + state.lastCheckInDate = today; + state.schemaVersion = 3; + saveState(workspacePath, state); +} diff --git a/src/lib/provider-resolver.ts b/src/lib/provider-resolver.ts index df40c59..8d3d3ef 100644 --- a/src/lib/provider-resolver.ts +++ b/src/lib/provider-resolver.ts @@ -485,11 +485,9 @@ function buildResolution( // Has credentials? const hasCredentials = !!(provider.api_key) || authStyle === 'env_only'; - // Settings sources — skip 'user' when a provider is configured to avoid - // ~/.claude/settings.json env overrides conflicting with provider config - const settingSources = hasCredentials - ? ['project', 'local'] - : ['user', 'project', 'local']; + // Settings sources — always include 'user' so SDK can load skills from + // ~/.claude/skills/. Env override conflicts are handled by envOverrides. + const settingSources = ['user', 'project', 'local']; return { provider, diff --git a/src/lib/stream-session-manager.ts b/src/lib/stream-session-manager.ts index 18a81fb..3db6097 100644 --- a/src/lib/stream-session-manager.ts +++ b/src/lib/stream-session-manager.ts @@ -64,6 +64,10 @@ export interface StartStreamParams { effort?: string; /** SDK thinking config */ thinking?: { type: string; budgetTokens?: number }; + /** Called when init status event provides metadata (tools, slash_commands, skills) */ + onInitMeta?: (meta: { tools?: unknown; slash_commands?: unknown; skills?: unknown }) => void; + /** Display-only content for user message (e.g. /skillName instead of expanded prompt) */ + displayOverride?: string; } // ========================================== @@ -236,6 +240,7 @@ async function runStream(stream: ActiveStream, params: StartStreamParams): Promi ...(params.autoTrigger ? { autoTrigger: true } : {}), ...(params.effort ? { effort: params.effort } : {}), ...(params.thinking ? { thinking: params.thinking } : {}), + ...(params.displayOverride ? { displayOverride: params.displayOverride } : {}), }), signal: stream.abortController.signal, }); @@ -344,6 +349,10 @@ async function runStream(stream: ActiveStream, params: StartStreamParams): Promi stream.accumulatedText = acc; emit(stream, 'snapshot-updated'); }, + onInitMeta: (meta) => { + markActive(); + params.onInitMeta?.(meta); + }, }); // Stream completed successfully — build final message content diff --git a/src/types/index.ts b/src/types/index.ts index c111124..a8963d6 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -51,6 +51,12 @@ export interface FilePreview { line_count: number; } +// ========================================== +// Skill / Command Types +// ========================================== + +export type SkillKind = 'agent_skill' | 'slash_command' | 'sdk_command' | 'codepilot_command'; + // ========================================== // Task Types // ========================================== @@ -452,7 +458,7 @@ export interface PluginInfo { } export interface MCPServerConfig { - command: string; + command?: string; args?: string[]; env?: Record; type?: 'stdio' | 'sse' | 'http';