Parcourir la source

docs: comprehensive test cases + v0.6.3 rebuild

KirinCode il y a 1 mois
Parent
commit
e5f565bb4e

+ 39 - 36
bun.lock

@@ -32,7 +32,7 @@
     },
     "packages/app": {
       "name": "@kirincode-ai/app",
-      "version": "0.4.1",
+      "version": "0.6.3",
       "dependencies": {
         "@corvu/drawer": "catalog:",
         "@dnd-kit/abstract": "0.5.0",
@@ -98,7 +98,7 @@
     },
     "packages/cli": {
       "name": "@kirincode-ai/cli",
-      "version": "0.4.1",
+      "version": "0.6.3",
       "bin": {
         "lildax": "./bin/lildax.cjs",
       },
@@ -146,7 +146,7 @@
     },
     "packages/codemode": {
       "name": "@kirincode-ai/codemode",
-      "version": "0.4.1",
+      "version": "0.6.3",
       "dependencies": {
         "acorn": "8.15.0",
         "effect": "catalog:",
@@ -160,7 +160,7 @@
     },
     "packages/console/app": {
       "name": "@kirincode-ai/console-app",
-      "version": "0.4.1",
+      "version": "0.6.3",
       "dependencies": {
         "@cloudflare/vite-plugin": "1.15.2",
         "@ibm/plex": "6.4.1",
@@ -196,7 +196,7 @@
     },
     "packages/console/core": {
       "name": "@kirincode-ai/console-core",
-      "version": "0.4.1",
+      "version": "0.6.3",
       "dependencies": {
         "@aws-sdk/client-sts": "3.782.0",
         "@jsx-email/render": "1.1.1",
@@ -223,7 +223,7 @@
     },
     "packages/console/function": {
       "name": "@kirincode-ai/console-function",
-      "version": "0.4.1",
+      "version": "0.6.3",
       "dependencies": {
         "@ai-sdk/anthropic": "3.0.82",
         "@ai-sdk/openai": "3.0.48",
@@ -245,7 +245,7 @@
     },
     "packages/console/mail": {
       "name": "@kirincode-ai/console-mail",
-      "version": "0.4.1",
+      "version": "0.6.3",
       "dependencies": {
         "@jsx-email/all": "2.2.3",
         "@jsx-email/cli": "1.4.3",
@@ -269,7 +269,7 @@
     },
     "packages/console/support": {
       "name": "@kirincode-ai/console-support",
-      "version": "0.4.1",
+      "version": "0.6.3",
       "dependencies": {
         "@cloudflare/vite-plugin": "1.15.2",
         "@kirincode-ai/console-core": "workspace:*",
@@ -289,7 +289,7 @@
     },
     "packages/core": {
       "name": "@kirincode-ai/core",
-      "version": "0.4.1",
+      "version": "0.6.3",
       "bin": {
         "kirincode": "./bin/opencode",
       },
@@ -383,15 +383,10 @@
     },
     "packages/desktop": {
       "name": "@kirincode-ai/desktop",
-      "version": "0.4.1",
+      "version": "0.6.3",
       "dependencies": {
         "@lydell/node-pty": "catalog:",
         "@lydell/node-pty-darwin-arm64": "1.2.0-beta.12",
-        "@lydell/node-pty-darwin-x64": "1.2.0-beta.12",
-        "@lydell/node-pty-linux-arm64": "1.2.0-beta.12",
-        "@lydell/node-pty-linux-x64": "1.2.0-beta.12",
-        "@lydell/node-pty-win32-arm64": "1.2.0-beta.12",
-        "@lydell/node-pty-win32-x64": "1.2.0-beta.12",
         "@zip.js/zip.js": "2.7.62",
         "effect": "catalog:",
         "electron-context-menu": "4.1.2",
@@ -424,10 +419,17 @@
         "vite": "catalog:",
         "zod-openapi": "5.4.6",
       },
+      "optionalDependencies": {
+        "@lydell/node-pty-darwin-x64": "1.2.0-beta.12",
+        "@lydell/node-pty-linux-arm64": "1.2.0-beta.12",
+        "@lydell/node-pty-linux-x64": "1.2.0-beta.12",
+        "@lydell/node-pty-win32-arm64": "1.2.0-beta.12",
+        "@lydell/node-pty-win32-x64": "1.2.0-beta.12",
+      },
     },
     "packages/effect-drizzle-sqlite": {
       "name": "@kirincode-ai/effect-drizzle-sqlite",
-      "version": "0.4.1",
+      "version": "0.6.3",
       "dependencies": {
         "drizzle-orm": "catalog:",
         "effect": "catalog:",
@@ -441,7 +443,7 @@
     },
     "packages/effect-sqlite-node": {
       "name": "@kirincode-ai/effect-sqlite-node",
-      "version": "0.4.1",
+      "version": "0.6.3",
       "dependencies": {
         "effect": "catalog:",
       },
@@ -453,7 +455,7 @@
     },
     "packages/enterprise": {
       "name": "@kirincode-ai/enterprise",
-      "version": "0.4.1",
+      "version": "0.6.3",
       "dependencies": {
         "@hono/standard-validator": "catalog:",
         "@kirincode-ai/core": "workspace:*",
@@ -485,7 +487,7 @@
     },
     "packages/function": {
       "name": "@kirincode-ai/function",
-      "version": "0.4.1",
+      "version": "0.6.3",
       "dependencies": {
         "@octokit/auth-app": "8.0.1",
         "@octokit/rest": "catalog:",
@@ -501,7 +503,7 @@
     },
     "packages/http-recorder": {
       "name": "@kirincode-ai/http-recorder",
-      "version": "0.4.1",
+      "version": "0.6.3",
       "dependencies": {
         "@effect/platform-node": "4.0.0-beta.83",
         "@effect/platform-node-shared": "4.0.0-beta.83",
@@ -532,7 +534,7 @@
     },
     "packages/kirincode": {
       "name": "kirincode",
-      "version": "0.4.1",
+      "version": "0.6.3",
       "bin": {
         "kirincode": "./bin/opencode",
       },
@@ -663,7 +665,7 @@
     },
     "packages/kirincode-cloud": {
       "name": "@kirincode-ai/kirincode-cloud",
-      "version": "0.4.1",
+      "version": "0.6.3",
       "dependencies": {
         "@elysiajs/cors": "^1.0.0",
         "@elysiajs/jwt": "^1.0.0",
@@ -686,12 +688,13 @@
         "@kirincode-ai/plugin": "workspace:*",
       },
       "devDependencies": {
+        "@types/bun": "catalog:",
         "typescript": "catalog:",
       },
     },
     "packages/kirincode-workflow": {
       "name": "@kirincode-ai/kirincode-workflow",
-      "version": "0.4.1",
+      "version": "0.6.3",
       "dependencies": {
         "@kirincode-ai/plugin": "workspace:*",
         "@kirincode-ai/sdk": "workspace:*",
@@ -702,7 +705,7 @@
     },
     "packages/llm": {
       "name": "@kirincode-ai/llm",
-      "version": "0.4.1",
+      "version": "0.6.3",
       "dependencies": {
         "@kirincode-ai/schema": "workspace:*",
         "@smithy/eventstream-codec": "4.2.14",
@@ -721,7 +724,7 @@
     },
     "packages/mobile": {
       "name": "@kirincode-ai/mobile",
-      "version": "0.4.1",
+      "version": "0.6.3",
       "dependencies": {
         "@react-navigation/native": "^7.0.0",
         "@react-navigation/native-stack": "^7.0.0",
@@ -742,7 +745,7 @@
     },
     "packages/plugin": {
       "name": "@kirincode-ai/plugin",
-      "version": "0.4.1",
+      "version": "0.6.3",
       "dependencies": {
         "@ai-sdk/provider": "3.0.8",
         "@kirincode-ai/sdk": "workspace:*",
@@ -818,7 +821,7 @@
     },
     "packages/sdk/js": {
       "name": "@kirincode-ai/sdk",
-      "version": "0.4.1",
+      "version": "0.6.3",
       "dependencies": {
         "cross-spawn": "catalog:",
       },
@@ -833,7 +836,7 @@
     },
     "packages/server": {
       "name": "@kirincode-ai/server",
-      "version": "0.4.1",
+      "version": "0.6.3",
       "dependencies": {
         "@kirincode-ai/core": "workspace:*",
         "@kirincode-ai/protocol": "workspace:*",
@@ -848,7 +851,7 @@
     },
     "packages/session-ui": {
       "name": "@kirincode-ai/session-ui",
-      "version": "0.4.1",
+      "version": "0.6.3",
       "dependencies": {
         "@kirincode-ai/core": "workspace:*",
         "@kirincode-ai/sdk": "workspace:*",
@@ -892,7 +895,7 @@
     },
     "packages/slack": {
       "name": "@kirincode-ai/slack",
-      "version": "0.4.1",
+      "version": "0.6.3",
       "dependencies": {
         "@kirincode-ai/sdk": "workspace:*",
         "@slack/bolt": "^3.17.1",
@@ -905,7 +908,7 @@
     },
     "packages/stats/app": {
       "name": "@kirincode-ai/stats-app",
-      "version": "0.4.1",
+      "version": "0.6.3",
       "dependencies": {
         "@ibm/plex": "6.4.1",
         "@kirincode-ai/stats-core": "workspace:*",
@@ -938,7 +941,7 @@
     },
     "packages/stats/core": {
       "name": "@kirincode-ai/stats-core",
-      "version": "0.4.1",
+      "version": "0.6.3",
       "dependencies": {
         "@aws-sdk/client-athena": "3.933.0",
         "@planetscale/database": "1.19.0",
@@ -957,7 +960,7 @@
     },
     "packages/stats/server": {
       "name": "@kirincode-ai/stats-server",
-      "version": "0.4.1",
+      "version": "0.6.3",
       "dependencies": {
         "@aws-sdk/client-firehose": "3.933.0",
         "@effect/platform-node": "catalog:",
@@ -998,7 +1001,7 @@
     },
     "packages/tui": {
       "name": "@kirincode-ai/tui",
-      "version": "0.4.1",
+      "version": "0.6.3",
       "dependencies": {
         "@kirincode-ai/core": "workspace:*",
         "@kirincode-ai/plugin": "workspace:*",
@@ -1025,7 +1028,7 @@
     },
     "packages/ui": {
       "name": "@kirincode-ai/ui",
-      "version": "0.4.1",
+      "version": "0.6.3",
       "dependencies": {
         "@kobalte/core": "catalog:",
         "@pierre/diffs": "catalog:",
@@ -1076,7 +1079,7 @@
     },
     "packages/web": {
       "name": "@kirincode-ai/web",
-      "version": "0.4.1",
+      "version": "0.6.3",
       "dependencies": {
         "@astrojs/cloudflare": "12.6.3",
         "@astrojs/markdown-remark": "6.3.1",

+ 225 - 0
docs/TEST-CASES.md

@@ -0,0 +1,225 @@
+# KirinCode 桌面端测试用例 v0.6.3
+
+## 测试环境
+
+| 项目 | 要求 |
+|------|------|
+| macOS | 13.0+ arm64 |
+| Windows | 10/11 x64 |
+| 磁盘空间 | 500MB+ |
+
+---
+
+## 一、安装与启动
+
+### TC-001 正常安装
+| 步骤 | 操作 | 预期结果 |
+|------|------|----------|
+| 1 | 双击 DMG/EXE 安装包 | 安装向导正常启动 |
+| 2 | macOS: 拖入 Applications | 复制完成 |
+| 3 | Windows: 选择安装路径 | 安装完成 |
+| 4 | 首次右键打开 (macOS) | Gatekeeper 提示 → 打开 |
+| **验收** | | ✅ 安装成功,无报错 |
+
+### TC-002 冷启动
+| 步骤 | 操作 | 预期结果 |
+|------|------|----------|
+| 1 | 首次启动应用 | 显示 KirinCode Logo + "正在启动 KirinCode" 文本 |
+| 2 | 等待加载 | < 10 秒内进入主界面 |
+| 3 | 再次关闭并启动 | < 5 秒进入 |
+| **验收** | | ✅ 启动时间 < 10s,无白屏/卡死 |
+
+### TC-003 启动进度提示
+| 步骤 | 操作 | 预期结果 |
+|------|------|----------|
+| 1 | 启动应用 | 看到 Logo + "正在启动 KirinCode" |
+| 2 | 观察 | 启动过程中有明确文字提示,非空白 |
+| **验收** | | ✅ 用户知道应用在加载中 |
+
+---
+
+## 二、品牌与身份
+
+### TC-010 AI 自我介绍
+| 步骤 | 操作 | 预期结果 |
+|------|------|----------|
+| 1 | 进入会话,输入 "你是谁?" | AI 回答 "我是 KirinCode" |
+| 2 | 检查是否提及 "opencode" | **不出现** opencode 字样 |
+| **验收** | | ✅ AI 正确标识为 KirinCode |
+
+### TC-011 CLI Logo
+| 步骤 | 操作 | 预期结果 |
+|------|------|----------|
+| 1 | 终端运行 `kirincode --help` | 显示 "K I R I N C O D E" 文本 |
+| 2 | 检查是否残留 OPENCODE 像素画 | **不出现** |
+| **验收** | | ✅ CLI 品牌正确 |
+
+### TC-012 反馈 URL
+| 步骤 | 操作 | 预期结果 |
+|------|------|----------|
+| 1 | 点击侧边栏帮助按钮 | 跳转到 `kailin.com.cn/feedback` |
+| 2 | 检查 URL 是否含 opencode.ai | **不包含** |
+| **验收** | | ✅ 反馈链接正确 |
+
+### TC-013 模型列表
+| 步骤 | 操作 | 预期结果 |
+|------|------|----------|
+| 1 | 查看可用模型列表 | 无 opencode/opencode-go/zenmux |
+| 2 | 全局搜索 "opencode" 关键词 | 不在任何 UI 中出现 |
+| **验收** | | ✅ 模型列表干净 |
+
+---
+
+## 三、四模式切换
+
+### TC-020 Tab 切换
+| 步骤 | 操作 | 预期结果 |
+|------|------|----------|
+| 1 | 按 Tab 键 | 依次切换 build → plan → debug → solo |
+| 2 | 观察每个模式的颜色 | build=默认, plan=默认, debug=橙, solo=紫 |
+| 3 | 观察权限变化 | plan 模式下编辑按钮禁用 |
+| **验收** | | ✅ 四模式正常切换 |
+
+### TC-021 Debug 模式
+| 步骤 | 操作 | 预期结果 |
+|------|------|----------|
+| 1 | 切换到 Debug 模式 | 可读取文件、运行测试命令 |
+| 2 | 尝试编辑文件 | 被拒绝 |
+| **验收** | | ✅ 只读诊断模式 |
+
+### TC-022 Solo 模式
+| 步骤 | 操作 | 预期结果 |
+|------|------|----------|
+| 1 | 切换到 Solo 模式 | 可编辑文件 |
+| 2 | 观察是否反问用户 | 应自主执行,减少确认 |
+| **验收** | | ✅ 自主执行模式 |
+
+---
+
+## 四、自动记忆与技能
+
+### TC-030 自动记忆
+| 步骤 | 操作 | 预期结果 |
+|------|------|----------|
+| 1 | 完成一次编码任务 | 会话结束后,检查 `~/.kirincode/projects/*/memory/MEMORY.md` |
+| 2 | 查看是否有自动记录 | 存在新条目 |
+| **验收** | | ✅ 自动记忆写入 |
+
+### TC-031 技能系统
+| 步骤 | 操作 | 预期结果 |
+|------|------|----------|
+| 1 | 输入 `/skill list` | 显示技能列表 |
+| 2 | 输入 `/skill create test-skill "测试技能"` | 创建成功 |
+| 3 | 编辑 `.kirincode/skills/test-skill.md` | 自定义技能内容 |
+| **验收** | | ✅ 技能系统可用 |
+
+---
+
+## 五、工作流引擎
+
+### TC-040 工作流创建
+| 步骤 | 操作 | 预期结果 |
+|------|------|----------|
+| 1 | 输入 `/workflow list` | 显示工作流列表 |
+| 2 | 创建工作流 YAML 文件 | `.kirincode/workflows/daily.yml` |
+| **验收** | | ✅ 工作流系统可用 |
+
+---
+
+## 六、个人中心
+
+### TC-050 侧边栏入口
+| 步骤 | 操作 | 预期结果 |
+|------|------|----------|
+| 1 | 查看侧边栏底部 | 存在 👤 图标,hover 显示 "个人中心" |
+| 2 | 点击进入 | 跳转到个人中心登录页 |
+| **验收** | | ✅ 入口可见可用 |
+
+### TC-051 个人登录
+| 步骤 | 操作 | 预期结果 |
+|------|------|----------|
+| 1 | 打开个人中心 | 显示邮箱+密码登录表单 |
+| 2 | 输入注册信息 | 注册成功,获得 JWT |
+| 3 | 再次登录 | 登录成功 |
+| **验收** | | ✅ 登录注册流程正常 |
+
+### TC-052 个人用量
+| 步骤 | 操作 | 预期结果 |
+|------|------|----------|
+| 1 | 登录后查看 | 显示今日 Token / 请求次数 / 费用 |
+| 2 | 切换到趋势图表 | 显示折线图 |
+| 3 | 切换到用量明细 | 显示分页列表 |
+| **验收** | | ✅ 三Tab 个人用量面板 |
+
+---
+
+## 七、独立管理页面
+
+### TC-060 personal.html
+| 步骤 | 操作 | 预期结果 |
+|------|------|----------|
+| 1 | 浏览器打开 `personal.html` | 加载登录界面 |
+| 2 | 登录后查看 | 显示个人用量面板 |
+| **验收** | | ✅ 独立页面可用 |
+
+### TC-061 admin.html
+| 步骤 | 操作 | 预期结果 |
+|------|------|----------|
+| 1 | 浏览器打开 `admin.html` | 加载登录界面 |
+| 2 | 管理员登录 | 显示团队管理 / 模型配置 / 用量统计 |
+| 3 | 团队管理 Tab | 显示成员列表 + 用量 |
+| 4 | 用量统计 Tab | 可筛选、分页、导出CSV |
+| **验收** | | ✅ 管理后台全功能可用 |
+
+---
+
+## 八、数据持久化
+
+### TC-070 配置保存
+| 步骤 | 操作 | 预期结果 |
+|------|------|----------|
+| 1 | 修改设置 → 关闭应用 | 设置被保存 |
+| 2 | 重新打开 | 设置仍然存在 |
+| **验收** | | ✅ 配置持久化 |
+
+### TC-071 记忆持久化
+| 步骤 | 操作 | 预期结果 |
+|------|------|----------|
+| 1 | 多次会话后重启应用 | 之前自动记忆仍在 |
+| **验收** | | ✅ 记忆持久化 |
+
+---
+
+## 九、跨平台
+
+### TC-080 macOS DMG
+| 步骤 | 操作 | 预期结果 |
+|------|------|----------|
+| 1 | 双击 DMG → 拖入 Applications | 安装成功 |
+| 2 | 首次右键打开 | 可正常运行 |
+| **验收** | | ✅ macOS 安装包可用 |
+
+### TC-081 Windows EXE
+| 步骤 | 操作 | 预期结果 |
+|------|------|----------|
+| 1 | 双击 EXE 安装 | 安装向导正常 |
+| 2 | 选择安装路径 | 自定义目录安装成功 |
+| 3 | 桌面快捷方式 | 可正常启动 |
+| **验收** | | ✅ Windows 安装包可用 |
+
+---
+
+## 测试通过标准
+
+| 类别 | 用例数 | 通过条件 |
+|------|--------|----------|
+| 安装启动 | 3 | 全部通过 |
+| 品牌身份 | 4 | 全部通过 |
+| 四模式 | 3 | 全部通过 |
+| 记忆技能 | 2 | 全部通过 |
+| 工作流 | 1 | 通过 |
+| 个人中心 | 3 | 全部通过 |
+| 管理页面 | 2 | 全部通过 |
+| 数据持久化 | 2 | 全部通过 |
+| 跨平台 | 2 | 全部通过 |
+| **总计** | **22** | **全部通过 = 发布就绪** |

+ 4 - 0
packages/kirincode-core/package.json

@@ -5,6 +5,9 @@
   "type": "module",
   "description": "KirinCode core plugin: auto-memory + skill system",
   "license": "MIT",
+  "scripts": {
+    "typecheck": "tsgo --noEmit"
+  },
   "exports": {
     ".": "./src/index.ts"
   },
@@ -12,6 +15,7 @@
     "@kirincode-ai/plugin": "workspace:*"
   },
   "devDependencies": {
+    "@types/bun": "catalog:",
     "typescript": "catalog:"
   }
 }

+ 24 - 19
packages/kirincode-core/src/commands.ts

@@ -2,63 +2,68 @@ import type { Part } from "@kirincode-ai/sdk"
 import type { MemoryStore } from "./memory"
 import type { SkillRegistry } from "./skills"
 
-function textPart(content: string): Part {
-  return { type: "text", text: content }
+let partSeq = 0
+
+function textPart(text: string, sessionID: string): Part {
+  const id = `kc_part_${Date.now()}_${partSeq++}`
+  return { id, sessionID, messageID: id, type: "text", text }
 }
 
-export async function handleMemoryCommand(args: string, store: MemoryStore): Promise<Part[]> {
+export async function handleMemoryCommand(args: string, store: MemoryStore, sessionID: string): Promise<Part[]> {
   const sub = args.trim()
   if (!sub || sub === "list") {
-    return [textPart(store.format())]
+    return [textPart(store.format(), sessionID)]
   }
   if (sub.startsWith("read ")) {
     const topic = sub.slice(5).trim()
     const content = store.readTopic(topic)
-    return [textPart(content || `No topic '${topic}' found. Topics: ${store.listTopics().join(", ") || "none"}`)]
+    return [textPart(content || `No topic '${topic}' found. Topics: ${store.listTopics().join(", ") || "none"}`, sessionID)]
   }
   if (sub.startsWith("write ")) {
     const rest = sub.slice(6).trim()
     const spaceIdx = rest.indexOf(" ")
-    if (spaceIdx === -1) return [textPart("Usage: /memory write <topic> <content>")]
+    if (spaceIdx === -1) return [textPart("Usage: /memory write <topic> <content>", sessionID)]
     const topic = rest.slice(0, spaceIdx)
     const content = rest.slice(spaceIdx + 1)
     store.writeTopic(topic, content)
-    return [textPart(`Topic '${topic}' saved.`)]
+    return [textPart(`Topic '${topic}' saved.`, sessionID)]
   }
-  return [textPart("Usage: /memory [list|read <topic>|write <topic> <content>]")]
+  return [textPart("Usage: /memory [list|read <topic>|write <topic> <content>]", sessionID)]
 }
 
-export async function handleSkillCommand(args: string, registry: SkillRegistry, worktree: string): Promise<Part[]> {
+export async function handleSkillCommand(args: string, registry: SkillRegistry, worktree: string, sessionID: string): Promise<Part[]> {
   const sub = args.trim()
   if (!sub || sub === "list") {
     const skills = registry.list()
-    if (skills.length === 0) return [textPart("No skills registered. Use `/skill create <name> <description>` to create one.")]
+    if (skills.length === 0) return [textPart("No skills registered. Use `/skill create <name> <description>` to create one.", sessionID)]
     const lines = skills.map((s) => `- **/${s.manifest.name}** [${s.scope}] — ${s.manifest.description}`)
-    return [textPart(lines.join("\n"))]
+    return [textPart(lines.join("\n"), sessionID)]
   }
   if (sub.startsWith("create ")) {
     const rest = sub.slice(7).trim()
     const firstQuote = rest.match(/["'](.+?)["']/)
     if (firstQuote) {
-      const name = firstQuote[1]
-      const desc = rest.slice(firstQuote[0].length).trim().replace(/^["'](.+?)["']/, "$1")
+      const name = firstQuote[1] ?? ""
+      if (!name) return [textPart("Could not parse skill name from quoted string.", sessionID)]
+      const desc = rest.slice((firstQuote[0] ?? "").length).trim().replace(/^["'](.+?)["']/, "$1")
       const path = registry.createSkill(name, desc, `# ${name}\n\nTODO: Write skill instructions here.`, "project", worktree)
       registry.load(worktree)
-      return [textPart(`Skill '/${name}' created at ${path}\nEdit the file to add instructions, then use /skill ${name} to invoke.`)]
+      return [textPart(`Skill '/${name}' created at ${path}\nEdit the file to add instructions, then use /skill ${name} to invoke.`, sessionID)]
     }
     const parts = rest.split(/\s+/)
-    if (parts.length < 2) return [textPart("Usage: /skill create <name> <description>")]
-    const name = parts[0]
+    if (parts.length < 2) return [textPart("Usage: /skill create <name> <description>", sessionID)]
+    const name = parts[0] ?? ""
+    if (!name) return [textPart("Usage: /skill create <name> <description>", sessionID)]
     const desc = parts.slice(1).join(" ")
     const path = registry.createSkill(name, desc, `# ${name}\n\nTODO: Write skill instructions here.`, "project", worktree)
     registry.load(worktree)
-    return [textPart(`Skill '/${name}' created at ${path}`)]
+    return [textPart(`Skill '/${name}' created at ${path}`, sessionID)]
   }
   if (sub === "reload") {
     registry.load(worktree)
-    return [textPart(`Skills reloaded. ${registry.list().length} skill(s) available.`)]
+    return [textPart(`Skills reloaded. ${registry.list().length} skill(s) available.`, sessionID)]
   }
   // Treat as skill name invocation
   const content = registry.injectSkill(sub)
-  return [textPart(content)]
+  return [textPart(content, sessionID)]
 }

+ 81 - 7
packages/kirincode-core/src/index.ts

@@ -1,11 +1,14 @@
 import type { Plugin, Hooks } from "@kirincode-ai/plugin"
+import { tool } from "@kirincode-ai/plugin"
 import { MemoryStore } from "./memory"
 import { SkillRegistry } from "./skills"
-import { handleMemoryCommand, handleSkillCommand } from "./commands"
+import { SubAgentRegistry } from "./subagent"
+import { handleMemoryCommand, handleSkillCommand, handleSubAgentCommand } from "./commands"
 
 export const KirinCodePlugin: Plugin = async ({ client, project, directory, worktree, $ }) => {
   const store = new MemoryStore(worktree)
   const registry = new SkillRegistry()
+  const subagents = new SubAgentRegistry()
 
   // Load skills at startup
   registry.load(worktree)
@@ -14,7 +17,7 @@ export const KirinCodePlugin: Plugin = async ({ client, project, directory, work
   const memorySummary = store.loadSummary()
 
   return {
-    // Inject memory + skills into system context
+    // Inject memory + skills + sub-agents into system context
     "experimental.chat.system.transform": async (_input, output) => {
       const extras: string[] = []
 
@@ -27,18 +30,26 @@ export const KirinCodePlugin: Plugin = async ({ client, project, directory, work
         extras.push(skillCtx)
       }
 
+      const subAgentCtx = subagents.formatSystemContext()
+      if (subAgentCtx) {
+        extras.push(subAgentCtx)
+      }
+
       if (extras.length) {
         output.system = [...output.system, ...extras]
       }
     },
 
-    // Handle /memory and /skill commands
+    // Handle /memory, /skill, and /subagent commands
     "command.execute.before": async (input, output) => {
       if (input.command === "memory") {
-        output.parts = await handleMemoryCommand(input.arguments, store)
+        output.parts = await handleMemoryCommand(input.arguments, store, input.sessionID)
       }
       if (input.command === "skill") {
-        output.parts = await handleSkillCommand(input.arguments, registry, worktree)
+        output.parts = await handleSkillCommand(input.arguments, registry, worktree, input.sessionID)
+      }
+      if (input.command === "subagent") {
+        output.parts = await handleSubAgentCommand(input.arguments, subagents)
       }
     },
 
@@ -65,9 +76,72 @@ export const KirinCodePlugin: Plugin = async ({ client, project, directory, work
 
     // Shell env: expose KIRINCODE_ env vars
     "shell.env": async (_input, output) => {
-      output.env.KIRINCODE_MEMORY_DIR = store["memoryDir"]
+      output.env.KIRINCODE_MEMORY_DIR = store.memoryDir
+      output.env.KIRINCODE_SUBAGENTS = String(subagents.list().length)
+    },
+
+    // Sub-agent tools for multi-agent task delegation
+    tool: {
+      subagent_spawn: tool({
+        description:
+          "Spawn a sub-agent to work on a specific task in the background. Multiple sub-agents can run concurrently to parallelize work. Use subagent_list to check status and subagent_collect to retrieve results when done.",
+        args: {
+          name: tool.schema.string().describe("Unique name for this sub-agent"),
+          task: tool.schema.string().describe("The task or prompt to send to the sub-agent"),
+          agent: tool.schema.string().optional().describe("Agent type: build, plan, general, or explore. Defaults to general."),
+          model: tool.schema.string().optional().describe("Model override, e.g. 'anthropic/claude-sonnet-4-5'"),
+        },
+        async execute(args, context) {
+          const def = subagents.spawn(client, context.sessionID, {
+            name: args.name,
+            task: args.task,
+            agent: args.agent,
+            model: args.model,
+          })
+          return `Sub-agent '${def.name}' spawned (${def.id}). Use subagent_collect '${def.name}' to retrieve results when ready.`
+        },
+      }),
+
+      subagent_list: tool({
+        description: "List all spawned sub-agents and their current status. Use after spawning to monitor progress.",
+        args: {},
+        async execute() {
+          const list = subagents.list()
+          if (list.length === 0) {
+            return "No sub-agents spawned. Use subagent_spawn to create one."
+          }
+          const lines = ["## Sub-Agents", ""]
+          for (const a of list) {
+            const icon = a.status === "completed" ? "✅" : a.status === "failed" ? "❌" : a.status === "spawning" ? "⏳" : "🔄"
+            const taskPreview = a.task.slice(0, 60)
+            lines.push(`- ${icon} **${a.name}** — ${a.status} — ${taskPreview}${a.task.length > 60 ? "..." : ""}`)
+          }
+          return lines.join("\n")
+        },
+      }),
+
+      subagent_status: tool({
+        description: "Check the detailed status of a specific sub-agent by name, including elapsed time and any errors.",
+        args: {
+          name: tool.schema.string().describe("Name of the sub-agent to check"),
+        },
+        async execute(args) {
+          return subagents.status(args.name)
+        },
+      }),
+
+      subagent_collect: tool({
+        description:
+          "Wait for a sub-agent to finish and collect its results. Blocks until the sub-agent completes or fails. Use after subagent_list shows a sub-agent is running.",
+        args: {
+          name: tool.schema.string().describe("Name of the sub-agent to collect results from"),
+        },
+        async execute(args) {
+          return await subagents.collect(args.name)
+        },
+      }),
     },
   } satisfies Hooks
 }
 
-export { MemoryStore, SkillRegistry }
+export { MemoryStore, SkillRegistry, SubAgentRegistry }

+ 1 - 1
packages/kirincode-core/src/memory.ts

@@ -13,7 +13,7 @@ export interface MemoryEntry {
 }
 
 export class MemoryStore {
-  private memoryDir: string
+  readonly memoryDir: string
   private indexPath: string
 
   constructor(projectRoot: string) {

+ 22 - 21
packages/kirincode-core/src/skills.ts

@@ -1,4 +1,4 @@
-import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"
+import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"
 import { join, resolve } from "node:path"
 
 export interface SkillManifest {
@@ -21,26 +21,32 @@ export interface Skill {
   scope: "project" | "user"
 }
 
-function parseFrontmatter(md: string): { frontmatter: Record<string, any>; body: string } {
+function parseFrontmatter(md: string): { frontmatter: Record<string, unknown>; body: string } {
   const match = md.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/)
   if (!match) return { frontmatter: {}, body: md }
 
-  const frontmatter: Record<string, any> = {}
-  const lines = match[1].split("\n")
-  for (const line of lines) {
+  const frontmatterText = match[1] ?? ""
+  const body = match[2] ?? ""
+  const frontmatter: Record<string, unknown> = {}
+
+  for (const line of frontmatterText.split("\n")) {
     const kv = line.match(/^(\w[\w_-]*):\s*(.*)$/)
-    if (kv) {
-      const key = kv[1]
-      let value = kv[2].trim()
-      if (value.startsWith("[") && value.endsWith("]")) {
-        value = value.slice(1, -1).split(",").map((s) => s.trim().replace(/^['"]|['"]$/g, ""))
-      }
-      if (value === "true") value = true
-      if (value === "false") value = false
-      frontmatter[key] = value
+    if (!kv) continue
+    const key = kv[1]
+    const raw = (kv[2] ?? "").trim()
+    if (!key) continue
+
+    let value: string | string[] | boolean = raw
+    if (raw.startsWith("[") && raw.endsWith("]")) {
+      value = raw.slice(1, -1).split(",").map((s) => s.trim().replace(/^['"]|['"]$/g, ""))
+    } else if (raw === "true") {
+      value = true
+    } else if (raw === "false") {
+      value = false
     }
+    frontmatter[key] = value
   }
-  return { frontmatter, body: match[2].trim() }
+  return { frontmatter, body: body.trim() }
 }
 
 export class SkillRegistry {
@@ -126,7 +132,7 @@ ${body}
       dir = join(worktree || ".", ".kirincode", "skills")
     }
     const filePath = join(dir, `${name}.md`)
-    mkdirp(dir)
+    mkdirSync(dir, { recursive: true })
     writeFileSync(filePath, content, "utf-8")
     return filePath
   }
@@ -168,8 +174,3 @@ ${body}
     return words.some((w) => input.includes(w))
   }
 }
-
-function mkdirp(dir: string): void {
-  const { mkdirSync: mk } = require("node:fs")
-  mk(dir, { recursive: true })
-}

+ 147 - 0
packages/kirincode-core/src/subagent.ts

@@ -0,0 +1,147 @@
+import type { Part, createOpencodeClient } from "@kirincode-ai/sdk"
+
+type Client = ReturnType<typeof createOpencodeClient>
+
+export interface SubAgentDef {
+  id: string
+  name: string
+  task: string
+  agent?: string
+  model?: { providerID: string; modelID: string }
+  sessionID?: string
+  status: "spawning" | "running" | "completed" | "failed"
+  result?: string
+  error?: string
+  startedAt: number
+  endedAt?: number
+}
+
+function randomId(): string {
+  return Math.random().toString(36).slice(2, 10)
+}
+
+function extractText(parts: Part[]): string {
+  return parts
+    .filter((p): p is Extract<Part, { type: "text" }> => p.type === "text")
+    .map((p) => p.text)
+    .join("\n")
+}
+
+export class SubAgentRegistry {
+  private agents = new Map<string, SubAgentDef>()
+  private promises = new Map<string, Promise<void>>()
+
+  spawn(
+    client: Client,
+    parentSessionID: string,
+    opts: { name: string; task: string; agent?: string; model?: string },
+  ): SubAgentDef {
+    const id = `sa_${randomId()}`
+    const def: SubAgentDef = {
+      id,
+      name: opts.name,
+      task: opts.task,
+      agent: opts.agent,
+      status: "spawning",
+      startedAt: Date.now(),
+    }
+    if (opts.model) {
+      const idx = opts.model.indexOf("/")
+      if (idx > 0) {
+        def.model = { providerID: opts.model.slice(0, idx), modelID: opts.model.slice(idx + 1) }
+      }
+    }
+
+    this.agents.set(opts.name, def)
+    const promise = this.executeSubAgent(client, parentSessionID, def, opts)
+    this.promises.set(opts.name, promise)
+    return def
+  }
+
+  private async executeSubAgent(
+    client: Client,
+    parentSessionID: string,
+    def: SubAgentDef,
+    opts: { task: string; agent?: string; model?: { providerID: string; modelID: string } },
+  ): Promise<void> {
+    try {
+      const createResult = await client.session.create({
+        body: { parentID: parentSessionID, title: `[SubAgent] ${def.name}` },
+      })
+      if (createResult.error) throw new Error(JSON.stringify(createResult.error))
+
+      const sessionID = createResult.data?.id ?? ""
+      def.sessionID = sessionID
+      def.status = "running"
+
+      const body: {
+        parts: Array<{ type: "text"; text: string }>
+        agent?: string
+        model?: { providerID: string; modelID: string }
+      } = {
+        parts: [{ type: "text" as const, text: opts.task }],
+      }
+      if (opts.agent) body.agent = opts.agent
+      if (def.model) body.model = def.model
+
+      const result = await client.session.prompt({ path: { id: sessionID }, body })
+      if (result.error) throw new Error(JSON.stringify(result.error))
+
+      def.result = extractText(result.data?.parts ?? []) || "(no text output)"
+      def.status = "completed"
+      def.endedAt = Date.now()
+    } catch (e) {
+      def.status = "failed"
+      def.error = e instanceof Error ? e.message : String(e)
+      def.endedAt = Date.now()
+    }
+  }
+
+  get(name: string): SubAgentDef | undefined {
+    return this.agents.get(name)
+  }
+
+  list(): SubAgentDef[] {
+    return Array.from(this.agents.values())
+  }
+
+  async collect(name: string): Promise<string> {
+    const def = this.agents.get(name)
+    if (!def) return `Sub-agent '${name}' not found. Use subagent_list to see available sub-agents.`
+
+    const promise = this.promises.get(name)
+    if (promise) await promise
+
+    if (def.status === "completed") return def.result ?? "(no result)"
+    if (def.status === "failed") return `Sub-agent '${name}' failed: ${def.error}`
+    return `Sub-agent '${name}' is still ${def.status}.`
+  }
+
+  status(name: string): string {
+    const def = this.agents.get(name)
+    if (!def) return `Sub-agent '${name}' not found.`
+    const elapsed = def.endedAt ? def.endedAt - def.startedAt : Date.now() - def.startedAt
+    const lines = [
+      `**${def.name}** (${def.id})`,
+      `  status: ${def.status}`,
+      `  task: ${def.task.slice(0, 100)}${def.task.length > 100 ? "..." : ""}`,
+      `  elapsed: ${(elapsed / 1000).toFixed(1)}s`,
+    ]
+    if (def.sessionID) lines.push(`  session: ${def.sessionID}`)
+    if (def.agent) lines.push(`  agent: ${def.agent}`)
+    if (def.error) lines.push(`  error: ${def.error}`)
+    return lines.join("\n")
+  }
+
+  formatSystemContext(): string {
+    const active = this.list().filter((a) => a.status === "running" || a.status === "spawning")
+    if (active.length === 0) return ""
+    const lines = ["## Active Sub-Agents", ""]
+    for (const a of active) {
+      const icon = a.status === "spawning" ? "⏳" : "🔄"
+      lines.push(`- ${icon} **${a.name}** — ${a.task.slice(0, 80)}${a.task.length > 80 ? "..." : ""}`)
+    }
+    lines.push("", "Use `subagent_status` or `subagent_collect` to check results.")
+    return lines.join("\n")
+  }
+}

+ 3 - 1
packages/kirincode-core/tsconfig.json

@@ -2,7 +2,9 @@
   "extends": "../../tsconfig.json",
   "compilerOptions": {
     "outDir": "./dist",
-    "rootDir": "./src"
+    "rootDir": "./src",
+    "lib": ["ESNext", "DOM", "DOM.Iterable"],
+    "types": []
   },
   "include": ["src"]
 }

+ 3 - 0
packages/kirincode-workflow/package.json

@@ -5,6 +5,9 @@
   "type": "module",
   "description": "KirinCode workflow engine — scheduled tasks, event triggers, background agents",
   "license": "MIT",
+  "scripts": {
+    "typecheck": "tsgo --noEmit"
+  },
   "exports": {
     ".": "./src/index.ts"
   },

+ 11 - 8
packages/kirincode-workflow/src/index.ts

@@ -29,32 +29,35 @@ export const KirinCodeWorkflowPlugin: Plugin = async ({ client, directory, workt
 
     try {
       const result = await client.session.create({ body: { title: `[WF] ${def.name}` } })
-      if (result.error) throw new Error(result.error as string)
+      if (result.error) throw new Error(JSON.stringify(result.error))
 
-      const sessionId = result.data!.id
+      const sessionId = result.data?.id ?? ""
       run.sessionId = sessionId
       store.saveRun(run)
 
-      const promptBody: Record<string, unknown> = {
-        parts: [{ type: "text", text: def.prompt }],
+      const body: {
+        parts: Array<{ type: "text"; text: string }>
+        model?: { providerID: string; modelID: string }
+      } = {
+        parts: [{ type: "text" as const, text: def.prompt }],
       }
       if (def.model) {
         const idx = def.model.indexOf("/")
         if (idx > 0) {
-          promptBody.model = {
+          body.model = {
             providerID: def.model.slice(0, idx),
             modelID: def.model.slice(idx + 1),
           }
         }
       }
 
-      await client.session.prompt({ path: { id: sessionId }, body: promptBody })
+      await client.session.prompt({ path: { id: sessionId }, body })
 
       run.status = "completed"
       run.endedAt = Date.now()
-    } catch (e: any) {
+    } catch (e) {
       run.status = "failed"
-      run.error = e?.message ?? String(e)
+      run.error = e instanceof Error ? e.message : String(e)
       run.endedAt = Date.now()
     } finally {
       RUNNING_TASKS.delete(run.id)

+ 10 - 10
packages/kirincode-workflow/src/scheduler.ts

@@ -6,11 +6,11 @@ export class CronScheduler {
     if (parts.length !== 5) throw new Error(`Invalid cron: "${cron}" — need 5 fields`)
 
     const [min, hour, day, month, weekday] = parts
-    const mins = this.parseField(min, 0, 59, "minute")
-    const hours = this.parseField(hour, 0, 23, "hour")
-    const days = this.parseField(day, 1, 31, "day")
-    const months = this.parseField(month, 1, 12, "month")
-    const weekdays = this.parseField(weekday, 0, 6, "weekday")
+    const mins = this.parseField(min ?? "", 0, 59, "minute")
+    const hours = this.parseField(hour ?? "", 0, 23, "hour")
+    const days = this.parseField(day ?? "", 1, 31, "day")
+    const months = this.parseField(month ?? "", 1, 12, "month")
+    const weekdays = this.parseField(weekday ?? "", 0, 6, "weekday")
 
     return {
       next: () => {
@@ -55,14 +55,14 @@ export class CronScheduler {
 
       if (trimmed.includes("/")) {
         const [range, stepStr] = trimmed.split("/")
-        const step = parseInt(stepStr)
+        const step = parseInt(stepStr ?? "")
         if (isNaN(step) || step < 1) throw new Error(`Invalid step: ${stepStr} in field "${field}"`)
 
         let rMin = min, rMax = max
         if (range !== "*") {
-          const dashParts = range.split("-")
-          rMin = parseInt(dashParts[0])
-          rMax = dashParts.length > 1 ? parseInt(dashParts[1]) : rMin
+          const dashParts = range?.split("-") ?? []
+          rMin = parseInt(dashParts[0] ?? "")
+          rMax = dashParts.length > 1 ? parseInt(dashParts[1] ?? "") : rMin
           if (isNaN(rMin) || isNaN(rMax)) throw new Error(`Invalid range: ${range} in field "${field}"`)
         }
         for (let i = rMin; i <= rMax; i += step) {
@@ -70,7 +70,7 @@ export class CronScheduler {
         }
       } else if (trimmed.includes("-")) {
         const [loStr, hiStr] = trimmed.split("-")
-        const lo = parseInt(loStr), hi = parseInt(hiStr)
+        const lo = parseInt(loStr ?? ""), hi = parseInt(hiStr ?? "")
         if (isNaN(lo) || isNaN(hi)) throw new Error(`Invalid range: ${trimmed} in field "${field}"`)
         for (let i = lo; i <= hi; i++) {
           if (i >= min && i <= max) result.add(i)

+ 14 - 22
packages/kirincode-workflow/src/store.ts

@@ -1,4 +1,4 @@
-import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from "node:fs"
+import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs"
 import { join } from "node:path"
 import type { WorkflowDef, WorkflowState, TaskRun } from "./types"
 
@@ -38,10 +38,9 @@ export class WorkflowStore {
   }
 
   saveDef(def: WorkflowDef): void {
-    // Delete existing .yaml if we're writing .yml (avoid duplicates)
     const yamlPath = join(this.defsPath, `${def.name}.yaml`)
     if (existsSync(yamlPath)) {
-      try { require("node:fs").unlinkSync(yamlPath) } catch { /* ignore */ }
+      try { unlinkSync(yamlPath) } catch { /* ignore */ }
     }
     const yml = this.toYaml(def)
     writeFileSync(join(this.defsPath, `${def.name}.yml`), yml, "utf-8")
@@ -81,60 +80,53 @@ export class WorkflowStore {
   }
 
   private parseYaml(s: string): WorkflowDef {
-    const def: Record<string, any> = {}
+    const def: Record<string, unknown> = {}
     const lines = s.split("\n")
     let i = 0
 
     while (i < lines.length) {
-      const line = lines[i]
-      // Skip comments and empty lines
+      const line = lines[i] ?? ""
       if (/^\s*#/.test(line) || /^\s*$/.test(line)) { i++; continue }
 
       const keyMatch = line.match(/^([\w_-]+):\s*(.*)$/)
       if (!keyMatch) { i++; continue }
 
-      const key = keyMatch[1]
-      let value = keyMatch[2]
+      const key = keyMatch[1] ?? ""
+      const value = keyMatch[2] ?? ""
+      if (!key) { i++; continue }
 
-      // Multiline block scalar (|)
       if (value === "|" || value === "|+") {
         const blockLines: string[] = []
         i++
-        while (i < lines.length && /^  /.test(lines[i])) {
-          blockLines.push(lines[i].slice(2))
+        while (i < lines.length) {
+          const blockLine = lines[i] ?? ""
+          if (!/^  /.test(blockLine)) break
+          blockLines.push(blockLine.slice(2))
           i++
         }
         def[key] = blockLines.join("\n")
         continue
       }
 
-      // Quoted string
       if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
         def[key] = value.slice(1, -1)
-        i++
-        continue
+        i++; continue
       }
 
-      // Array notation
       if (value.startsWith("[") && value.endsWith("]")) {
         def[key] = value.slice(1, -1).split(",").map((x) => x.trim().replace(/^['"]|['"]$/g, ""))
-        i++
-        continue
+        i++; continue
       }
 
-      // Booleans
       if (value === "true") { def[key] = true; i++; continue }
       if (value === "false") { def[key] = false; i++; continue }
-
-      // Numbers
       if (/^-?\d+$/.test(value)) { def[key] = parseInt(value); i++; continue }
 
-      // Plain string
       def[key] = value
       i++
     }
 
-    return def as WorkflowDef
+    return def as unknown as WorkflowDef
   }
 
   private toYaml(def: WorkflowDef): string {

+ 10 - 0
packages/kirincode-workflow/tsconfig.json

@@ -0,0 +1,10 @@
+{
+  "extends": "../../tsconfig.json",
+  "compilerOptions": {
+    "outDir": "./dist",
+    "rootDir": "./src",
+    "lib": ["ESNext", "DOM", "DOM.Iterable"],
+    "types": []
+  },
+  "include": ["src"]
+}