From 9f0440e15dbe894846a1f8f14339bb33217618aa Mon Sep 17 00:00:00 2001 From: mahuixin Date: Sat, 24 Oct 2026 03:35:38 +0800 Subject: [PATCH 1/2] Add read-only native sub-agent flow MiniApp --- README.md | 1 + README.zh-CN.md | 1 + .../agent-flow/.minimax-plugin/plugin.json | 14 + plugins/huixin615/agent-flow/LICENSE | 21 + plugins/huixin615/agent-flow/README.md | 36 ++ plugins/huixin615/agent-flow/README.zh-CN.md | 36 ++ plugins/huixin615/agent-flow/icon.png | Bin 0 -> 697 bytes .../agent-flow/miniapp/client/app.css | 480 ++++++++++++++++++ .../agent-flow/miniapp/client/app.js | 294 +++++++++++ .../agent-flow/miniapp/client/client-api.ts | 387 ++++++++++++++ .../agent-flow/miniapp/client/index.html | 48 ++ .../huixin615/agent-flow/miniapp/miniapp.json | 16 + .../agent-flow/miniapp/node/data.mjs | 366 +++++++++++++ .../agent-flow/miniapp/node/miniapp-api.ts | 397 +++++++++++++++ .../agent-flow/miniapp/node/server.mjs | 79 +++ plugins/huixin615/agent-flow/package.json | 6 + .../huixin615/agent-flow/tests/data.test.mjs | 260 ++++++++++ 17 files changed, 2442 insertions(+) create mode 100644 plugins/huixin615/agent-flow/.minimax-plugin/plugin.json create mode 100644 plugins/huixin615/agent-flow/LICENSE create mode 100644 plugins/huixin615/agent-flow/README.md create mode 100644 plugins/huixin615/agent-flow/README.zh-CN.md create mode 100644 plugins/huixin615/agent-flow/icon.png create mode 100644 plugins/huixin615/agent-flow/miniapp/client/app.css create mode 100644 plugins/huixin615/agent-flow/miniapp/client/app.js create mode 100644 plugins/huixin615/agent-flow/miniapp/client/client-api.ts create mode 100644 plugins/huixin615/agent-flow/miniapp/client/index.html create mode 100644 plugins/huixin615/agent-flow/miniapp/miniapp.json create mode 100644 plugins/huixin615/agent-flow/miniapp/node/data.mjs create mode 100644 plugins/huixin615/agent-flow/miniapp/node/miniapp-api.ts create mode 100644 plugins/huixin615/agent-flow/miniapp/node/server.mjs create mode 100644 plugins/huixin615/agent-flow/package.json create mode 100644 plugins/huixin615/agent-flow/tests/data.test.mjs diff --git a/README.md b/README.md index 06a55e07..d76b6750 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ The MiniMax Code Agent communicates through the host MCP client. Business reques | [Disk Insight](plugins/zch0227/disk-insight/) | See where disk space went on macOS and Windows with a sunburst space map, find junk files sorted into three safety tiers, large and duplicate files, and how much code repositories and Git worktrees take; ask an Agent about any item, and permanently delete what you select after a confirmation that lists every item. Requires MiniMax Code 3.1.2 or later | [zch0227](https://github.com/zch0227) | | [A/时差保安 / Timezone Guard](plugins/liuchen19960902/timezone-guard/) | Check the other side's local time before you message: the target city's clock turns into a green "now is fine", yellow "keep it short", or red "go to sleep" verdict. Requires MiniMax Code 3.1.2 or later | [FineCat](https://github.com/LiuChen19960902) | | [AI Node Canvas](plugins/antianqi/ai-node-canvas/) | An infinite-canvas node graph for MiniMax Code: wire text, image, video and audio runs into a graph, let each result flow downstream, and run media tools on any result | [antianqi](https://github.com/antianqi) | +| [Agent Flow](plugins/huixin615/agent-flow/) | Inspect native sub-agent timelines across parent and child sessions: actual serial or overlapping execution, child tool activity, and result matches in the current parent history. Requires MiniMax Code 3.1.4 or later | [Huixin615](https://github.com/Huixin615) |
Preview: Token Usage Board diff --git a/README.zh-CN.md b/README.zh-CN.md index 672ae6c1..96eedccf 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -78,6 +78,7 @@ MiniMax Code Agent 通过宿主 MCP 客户端与 MiniApp 协作。业务请求 | [磁盘透视](plugins/zch0227/disk-insight/README.zh-CN.md) | 查看 macOS 和 Windows 的磁盘空间去向:旭日图空间地图,按三档风险分类的垃圾文件,大文件和重复文件,代码仓库和 Git worktree 的占用;任何条目都可以问 Agent,选中的条目在确认框里逐一列出,确认后直接永久删除。需要 MiniMax Code 3.1.2 或更高版本 | [zch0227](https://github.com/zch0227) | | [A/时差保安](plugins/liuchen19960902/timezone-guard/README.zh-CN.md) | 发消息前先看对方那边几点:按对方城市当地时间给出「现在可以聊 / 短聊就好 / 先睡,别发」三档判定。需要 MiniMax Code 3.1.2 或更高版本 | [FineCat](https://github.com/LiuChen19960902) | | [AI 节点画布](plugins/antianqi/ai-node-canvas/README.zh-CN.md) | MiniMax Code 里的无限画布节点图:把文本、图片、视频、音频的生成接成一张图,结果顺着连线往下传,任何一张结果都能直接做二次处理 | [antianqi](https://github.com/antianqi) | +| [Agent 协作透视](plugins/huixin615/agent-flow/README.zh-CN.md) | 跨父子会话查看原生子 Agent 时间线:实际串行或重叠执行、子任务工具活动,以及当前父会话历史中的回传匹配情况;本机只读。需要 MiniMax Code 3.1.4 或更高版本 | [Huixin615](https://github.com/Huixin615) |
预览:Token 用量看板 diff --git a/plugins/huixin615/agent-flow/.minimax-plugin/plugin.json b/plugins/huixin615/agent-flow/.minimax-plugin/plugin.json new file mode 100644 index 00000000..126b3146 --- /dev/null +++ b/plugins/huixin615/agent-flow/.minimax-plugin/plugin.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "name": "agent-flow", + "displayName": "Agent 协作透视", + "version": "1.0.0", + "description": "Read-only timeline of native sub-agent tasks, child sessions, and result handoffs in local MiniMax Code conversations.", + "author": "Huixin615", + "icon": "icon.png", + "category": "Code", + "exampleQueries": ["打开 Agent 协作透视", "查看这次任务的子 Agent 协作过程"], + "apps": [], + "mcpServers": [], + "skills": [] +} diff --git a/plugins/huixin615/agent-flow/LICENSE b/plugins/huixin615/agent-flow/LICENSE new file mode 100644 index 00000000..c2d2bd55 --- /dev/null +++ b/plugins/huixin615/agent-flow/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Huixin615 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/huixin615/agent-flow/README.md b/plugins/huixin615/agent-flow/README.md new file mode 100644 index 00000000..1604f374 --- /dev/null +++ b/plugins/huixin615/agent-flow/README.md @@ -0,0 +1,36 @@ +# Agent Flow (`agent-flow`) + +A read-only Mini App for inspecting **native `task` sub-agents** in local MiniMax Code conversations. Open it while working normally: it shows when a parent Agent delegated work, the child session and its tool activity, the child's final reply, and whether a matching result appeared in the parent conversation. It does not launch or control Agents. + +The timeline uses persisted task times and reports whether tasks delegated in one turn actually overlapped, plus whether each result can be matched in the current parent history. Two tool calls in one parent message are not assumed to have run in parallel. A task marked “delivered” is not treated as a verified parent-history match; compaction or an unreadable history can leave that match unverified. A match does not prove the parent understood or used the result. + +## Install and use + +Requires MiniMax Code 3.1.4 or later. + +Copy this whole directory, including `.minimax-plugin/`, to `~/.minimax/plugins/agent-flow/`. Restart MiniMax Code, then ask it to “Open Agent Flow” or “打开 Agent 协作透视”. The Mini App tab belongs to the conversation where it was opened; the page can still inspect earlier conversations through its picker. It follows the most recently updated local conversation as an **inference**, including conversations with zero tasks, because the Host does not expose the current chat ID to this Mini App. Use the picker to hold a specific conversation, or choose “Follow latest” to resume automatic selection. It refreshes task data every three seconds and the conversation list every fifteen seconds while open; it stops when closed. + +The detail panel shows the `task` instruction, the child Agent's final reply, and tool names. These are recorded statements and actions, not proof that an advertised code change is correct. Previous messages removed from the active history by compaction may no longer be available in the detail panel; the persisted task and parent-child relation remain visible. The session picker starts with 40 entries and offers “Load more” when needed. + +This version covers native `task` sub-agents only. MiniMax Bot groups, MoMo-managed tasks, cloud sessions, and arbitrary plugin-owned Agent sessions are outside its scope. + +## Tested environment + +- MiniMax Code 3.1.4 on macOS: read-only reconstruction verified against one real parent conversation with two foreground `explore` children and matching parent tool results. The plugin appears in the installed plugin list, and the user confirmed that the Mini App page displays both tasks. +- Node.js 24.21.0: synthetic tests cover old sessions, mismatched paths, missing parent results, and nested failed background tasks; the repository `npm run check` passes. +- Windows paths are implemented with Node's cross-platform path utilities but have not been tested on Windows. + +## Data & access + +- Reads `~/.minimax/v2/sqlite/runtime-state.sqlite` with `node:sqlite` in read-only mode, plus selected session `messages.jsonl` files under `~/.minimax/v2/sessions`. It checks that a history directory encodes the selected session ID, rejects symlinked history paths, and caches unchanged files while open. If `MAVIS_HOME` is set, that directory replaces `~/.minimax`. +- The Mini App Host does not provide a supported API to observe other conversations. These paths and schemas are **internal, undocumented layouts** observed in MiniMax Code 3.1.4; a client update may break them. A missing or incompatible store produces an error, not guessed relationships. +- Does not write Host files, plugin data, or browser storage. No outbound network requests, spawned processes, Agent turns, MCP endpoints, or background monitoring after the page closes. +- The browser receives only selected conversation and task details from the local Node process. Nothing is uploaded. The page renders stored text as text, never as HTML. + +## Development + +Run `node --test plugins/huixin615/agent-flow/tests/*.test.mjs` and `npm run check` from the repository root. Synthetic tests create temporary stores and contain no real conversation content. + +## License + +[MIT](./LICENSE) diff --git a/plugins/huixin615/agent-flow/README.zh-CN.md b/plugins/huixin615/agent-flow/README.zh-CN.md new file mode 100644 index 00000000..59902090 --- /dev/null +++ b/plugins/huixin615/agent-flow/README.zh-CN.md @@ -0,0 +1,36 @@ +# Agent 协作透视(`agent-flow`) + +一个只读 MiniApp:用户照常在 MiniMax Code 对话,插件展示原生 `task` 子 Agent 何时启动、执行了哪些工具、最终回复了什么,以及结果是否出现在父会话。插件不创建或控制 Agent。 + +时间线使用任务实际起止时间,并对同一轮委派的任务标出运行区间是否重叠及结果在当前父会话历史中的匹配情况。父 Agent 同一条消息中提出两个任务,不代表两者并行。任务记录中的“已交付”不等于父会话历史已核实回传;历史被压缩或无法读取时会标为暂无法核实。即使匹配成功,也不表示父 Agent 已理解、采用或验证结果。 + +## 安装与使用 + +需要 MiniMax Code 3.1.4 或更高版本。 + +将整个目录(含 `.minimax-plugin/`)复制到 `~/.minimax/plugins/agent-flow/`,重启 MiniMax Code,然后说“打开 Agent 协作透视”。MiniApp 标签页附属于打开它的会话;在别的会话打开后,仍可在页面内选择之前的会话。宿主没有向插件提供当前聊天的 ID,因此页面**推断**最近更新的本机会话并自动跟随,包括尚无子任务的新会话;手动选择会话后保持选择,点击“跟随最新会话”可恢复自动跟随。页面打开时每三秒刷新任务、每十五秒刷新会话列表,关闭后不运行后台监控。 + +点击子任务可查看父 Agent 发出的 `task` 指令、子 Agent 最终回复与工具名称。这些内容是记录中的陈述和操作,不能单独证明代码修改正确。若旧消息已被压缩移出活动历史,详情可能缺失,但持久化的任务与父子关系仍可见。会话列表先显示 40 条,需要时可点“加载更多会话”。 + +首版只覆盖原生 `task` 子 Agent,不覆盖 MiniMax Bot 群聊、MoMo 自管任务、云端会话或任意插件自建 Agent 会话。 + +## Tested environment + +- MiniMax Code 3.1.4 / macOS:已用一条含两个前台 `explore` 子 Agent 的真实本机会话验证任务、子会话、父会话结果可以关联;插件已显示在“插件·技能”列表,用户在桌面端打开页面并确认显示两项子任务。 +- Node.js 24.21.0:合成测试覆盖旧会话、错误路径、父会话回传缺失以及嵌套的失败后台任务;已运行仓库检查。 +- 使用 Node 跨平台路径 API;Windows 尚未实测。 + +## Data & access + +- 只读打开 `~/.minimax/v2/sqlite/runtime-state.sqlite`,并读取 `~/.minimax/v2/sessions` 中所选会话的 `messages.jsonl`;读取前核对历史目录编码的会话 ID,拒绝符号链接路径,运行期间缓存未变化的历史文件。设置 `MAVIS_HOME` 时以该目录为根。 +- MiniApp 宿主没有提供观察其他普通会话的稳定 API。以上路径和结构是 MiniMax Code 3.1.4 中观察到的**未承诺稳定的内部格式**;版本变化可能导致读取失败。关联不一致时显示错误或警告,不猜测关系。 +- 不写入宿主文件、插件数据或浏览器存储;不向外联网、不启动进程或 Agent、不提供 MCP,也不在页面关闭后继续监控。 +- 文本只送到本机 MiniApp 页面,并按纯文本渲染。 + +## 开发 + +在仓库根目录运行 `node --test plugins/huixin615/agent-flow/tests/*.test.mjs` 和 `npm run check`。合成测试不包含真实会话内容。 + +## License + +[MIT](./LICENSE) diff --git a/plugins/huixin615/agent-flow/icon.png b/plugins/huixin615/agent-flow/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..5f4718c4d225517eab2439afe38681d692be312d GIT binary patch literal 697 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1SEZ8zRh7^U~2GmaSW-r_4bafAG4uA+ruj& zCM6|TVj8UjFBPduN#z~33=-&6b6WFw{>L-VKWh8#-NA;d0n5MSOI_ffF|V$jQP%MBm)XCPu5V{Zm{#Z8yrw{$f$gIBH`cFr z`3?m9GOg!gSpJ2P!!Q1Ox^cPkwv70zm{eCM-cu?^#zdxA@@H@RiCIi~t&{=9l&*1H+}ZoXfdd5Zov z^yz)H=~?#WH_sNUq{q@~XAF7low>iX|Y&d=1~{Z`$JjrXQ646gtTn<G~i)z4*}Q$iB}HgX4i literal 0 HcmV?d00001 diff --git a/plugins/huixin615/agent-flow/miniapp/client/app.css b/plugins/huixin615/agent-flow/miniapp/client/app.css new file mode 100644 index 00000000..777d8787 --- /dev/null +++ b/plugins/huixin615/agent-flow/miniapp/client/app.css @@ -0,0 +1,480 @@ +:root { + font-family:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; + color:#eaf1fb; + background:#0a1322; + font-synthesis:none +} +* { + box-sizing:border-box +} +body { + margin:0; + min-height:100vh; + background:radial-gradient(circle at 85% -10%,#172f4c 0,transparent 34%),#0a1322 +} +button,select { + font:inherit +} +button { + cursor:pointer +} +.masthead { + height:76px; + display:flex; + align-items:center; + gap:15px; + padding:0 30px; + border-bottom:1px solid #23354c; + background:#0e1a2b +} +.brand-mark { + height:42px; + width:42px; + border:1px solid #496c94; + border-radius:13px; + display:flex; + align-items:center; + justify-content:center; + gap:3px; + background:#132b48 +} +.brand-mark span { + width:5px; + background:#73b7ff; + border-radius:3px +} +.brand-mark span:nth-child(1) { + height:11px +} +.brand-mark span:nth-child(2) { + height:25px; + background:#8cddbe +} +.brand-mark span:nth-child(3) { + height:16px +} +.brand-copy { + display:grid; + gap:2px +} +.brand-copy strong { + font-size:17px; + letter-spacing:.02em +} +.brand-copy small { + font-size:12px; + color:#91a9c7 +} +.local-badge { + margin-left:auto; + padding:8px 11px; + border:1px solid #315b4b; + border-radius:999px; + color:#98d8b5; + background:#173228; + font-size:12px +} +main { + max-width:1480px; + margin:auto; + padding:28px 30px 36px +} +.toolbar { + display:flex; + justify-content:space-between; + align-items:flex-end; + gap:20px +} +.eyebrow { + font-size:10px; + font-weight:800; + letter-spacing:.17em; + color:#70b6ff; + text-transform:uppercase; + margin-bottom:8px +} +select { + width:min(560px,75vw); + max-width:100%; + background:#132237; + border:1px solid #35506d; + color:#eaf1fb; + border-radius:10px; + padding:12px 14px; + outline:none +} +.hint { + color:#90a4be; + font-size:12px +} +.toolbar p { + margin:7px 0 0 +} +.toolbar-right { + display:flex; + align-items:center; + gap:12px +} +.toolbar button,.copy { + padding:9px 13px; + color:#cde5ff; + background:#19304e; + border:1px solid #3c5b7e; + border-radius:9px +} +.toolbar button:hover,.copy:hover { + background:#254466 +} +.error { + margin-top:20px; + padding:12px 14px; + border:1px solid #9f626b; + border-radius:10px; + background:#3b2029; + color:#ffc2cc +} +.overview { + display:grid; + grid-template-columns:repeat(4,minmax(0,1fr)); + gap:12px; + margin:26px 0 +} +.stat { + border:1px solid #263e5c; + background:#102036; + border-radius:13px; + padding:17px 19px; + display:grid; + gap:7px +} +.stat span { + font-size:12px; + color:#8fa5c0 +} +.stat strong { + font-size:28px; + line-height:1.1 +} +.observation { + border:1px solid #35516e; + border-radius:13px; + padding:15px 18px; + background:#142640; + margin:-11px 0 24px; + font-size:13px; + line-height:1.5 +} +.observation strong { + display:block; + color:#9fd0ff; + margin-bottom:7px +} +.observation p { + margin:4px 0; + color:#c9d9ed +} +.workspace { + display:grid; + grid-template-columns:minmax(0,1.45fr) minmax(320px,1fr); + gap:16px; + align-items:start +} +.timeline-column,.detail-column { + min-height:520px; + border:1px solid #263e5c; + background:#0e1b2e; + border-radius:16px; + overflow:hidden +} +.section-heading { + padding:20px 22px 17px; + border-bottom:1px solid #253b56; + display:flex; + align-items:end; + justify-content:space-between; + gap:8px +} +h1,h2 { + margin:0; + font-size:18px +} +.flow { + padding:20px 22px +} +.root-card { + padding:15px 17px; + border-radius:12px; + border:1px solid #4177af; + background:#163252; + margin-bottom:18px +} +.root-card .label { + display:block; + color:#91bde8; + font-size:11px; + text-transform:uppercase; + font-weight:800; + letter-spacing:.1em +} +.root-card strong { + display:block; + margin-top:5px; + font-size:16px; + overflow-wrap:anywhere +} +.root-card small { + display:block; + margin-top:5px; + color:#a4bbd5 +} +.task-list { + border-left:2px solid #38597c; + margin-left:17px; + padding-left:20px; + display:grid; + gap:13px +} +.task-card { + position:relative; + width:100%; + text-align:left; + color:inherit; + background:#13243b; + border:1px solid #35516e; + border-radius:12px; + padding:15px; + display:grid; + gap:10px +} +.task-card:before { + content:""; + position:absolute; + left:-22px; + top:25px; + width:20px; + border-top:2px solid #38597c +} +.task-card:hover,.task-card.selected { + border-color:#78b8ff; + background:#1a3452 +} +.task-card.nested { + margin-left:calc(min(var(--depth), 6) * 18px); + width:calc(100% - min(var(--depth), 6) * 18px) +} +.task-top,.task-meta { + display:flex; + justify-content:space-between; + gap:12px; + align-items:center +} +.task-title { + font-size:14px; + font-weight:700; + overflow-wrap:anywhere +} +.task-meta { + font-size:12px; + color:#a0b4cc; + flex-wrap:wrap +} +.status { + font-size:11px; + font-weight:700; + white-space:nowrap; + padding:5px 8px; + border-radius:999px; + background:#29466a; + color:#add4ff +} +.status.succeeded { + background:#1b4436; + color:#9fe3b9 +} +.status.failed,.status.canceled,.status.lost { + background:#512d37; + color:#ffc1cb +} +.status.running,.status.queued,.status.stopping { + background:#584524; + color:#ffdc93 +} +.rail { + height:6px; + background:#233a56; + border-radius:10px; + overflow:hidden +} +.rail span { + display:block; + height:100%; + border-radius:10px; + background:#72b4f9 +} +.rail span[hidden] { + display:none +} +.task-card.succeeded .rail span { + background:#7bd5ac +} +.task-card.failed .rail span { + background:#ff8896 +} +.flow-arrow { + display:flex; + align-items:center; + gap:7px; + color:#8da8c4; + font-size:11px +} +.flow-arrow:before { + content:"↳"; + color:#78b8ff; + font-size:17px +} +.empty { + padding:45px 18px; + color:#8fa5bd; + text-align:center; + line-height:1.7 +} +.detail { + padding:21px 22px +} +.detail h3 { + font-size:18px; + margin:0 0 9px; + overflow-wrap:anywhere +} +.detail .subline { + font-size:12px; + color:#9ab0c9; + margin-bottom:16px +} +.facts { + display:grid; + grid-template-columns:100px minmax(0,1fr); + gap:9px 12px; + padding:14px 0; + border-top:1px solid #263b55; + border-bottom:1px solid #263b55; + font-size:12px +} +.facts dt { + color:#8da5c1 +} +.facts dd { + margin:0; + overflow-wrap:anywhere +} +.detail-section { + margin-top:20px +} +.detail-section .head { + display:flex; + align-items:center; + justify-content:space-between; + gap:12px; + margin-bottom:9px +} +.detail-section h4 { + margin:0; + font-size:13px; + color:#b9d6f5 +} +.detail-section pre { + white-space:pre-wrap; + word-break:break-word; + max-height:230px; + overflow:auto; + background:#0a1525; + border:1px solid #2b435f; + border-radius:10px; + padding:13px; + margin:0; + color:#d8e7f8; + font:12px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace +} +.detail-section .none { + color:#8fa5bd; + font-size:12px; + line-height:1.6 +} +.copy { + font-size:11px; + padding:6px 9px +} +.tools { + display:flex; + gap:6px; + flex-wrap:wrap +} +.tool-pill { + padding:5px 7px; + background:#1b3452; + border:1px solid #35516e; + border-radius:7px; + font:11px ui-monospace,SFMono-Regular,Menlo,monospace +} +.warning { + font-size:12px; + line-height:1.5; + color:#ffd28f +} +.footnote { + color:#859bb4; + font-size:11px; + line-height:1.6; + margin:19px 1px 0 +} +@media(max-width:850px) { + main { + padding:20px 16px + } + .masthead { + padding:0 16px + } + .workspace { + grid-template-columns:1fr + } + .detail-column { + min-height:260px + } + .overview { + grid-template-columns:repeat(2,1fr) + } + .toolbar { + align-items:stretch; + flex-direction:column + } + .toolbar-right { + justify-content:space-between + } + select { + width:100% + } +} +@media(max-width:480px) { + .masthead { + gap:9px + } + .brand-copy strong { + font-size:14px + } + .brand-copy small { + font-size:10px + } + .local-badge { + font-size:10px; + padding:6px + } + .overview { + gap:8px + } + .stat { + padding:12px + } + .stat strong { + font-size:22px + } +} diff --git a/plugins/huixin615/agent-flow/miniapp/client/app.js b/plugins/huixin615/agent-flow/miniapp/client/app.js new file mode 100644 index 00000000..bceea8cd --- /dev/null +++ b/plugins/huixin615/agent-flow/miniapp/client/app.js @@ -0,0 +1,294 @@ +const dom = Object.fromEntries([ + 'session-select', 'selection-note', 'follow-latest', 'load-more-sessions', 'refresh-state', 'refresh-button', 'error', + 'total', 'running', 'done', 'failed', 'observation', 'flow', 'detail', +].map((id) => [id, document.getElementById(id)])); + +let selectedId = null; +let selectedTaskId = null; +let manualSelection = false; +let lastSignature = ''; +let currentFlow = null; +let requestSequence = 0; +let detailSequence = 0; +let lastDetailKey = ''; +let missingFinalRetries = 0; +let sessionLimit = 40; +let sessionSequence = 0; + +function node(tag, className, text) { + const element = document.createElement(tag); + if (className) element.className = className; + if (text !== undefined) element.textContent = String(text); + return element; +} + +function time(ms) { + return Number.isFinite(ms) && ms > 0 + ? new Date(ms).toLocaleTimeString('zh-CN', { hour12: false }) : '—'; +} + +function duration(ms) { + if (!Number.isFinite(ms) || ms < 0) return '—'; + return ms < 1000 ? `${Math.round(ms)} 毫秒` : `${(ms / 1000).toFixed(1)} 秒`; +} + +const statusNames = { + queued: '排队', running: '运行中', stopping: '停止中', succeeded: '已完成', + failed: '失败', canceled: '已取消', lost: '中断', +}; + +async function getJson(path) { + let response; + try { response = await fetch(path, { cache: 'no-store' }); } + catch { throw new Error('插件运行时已断开。请点击上方 ▶ 启动,然后刷新页面。'); } + const data = await response.json(); + if (!response.ok) throw new Error(data.message || '读取失败'); + return data; +} + +function showError(message) { + dom.error.hidden = !message; + dom.error.textContent = message || ''; +} + +async function loadSessions() { + const sequence = ++sessionSequence; + try { + const pin = selectedId ? `&pin=${encodeURIComponent(selectedId)}` : ''; + const { sessions, hasMore } = await getJson(`/api/sessions?limit=${sessionLimit}${pin}`); + if (sequence !== sessionSequence) return; + const previous = selectedId; + const newest = sessions[0]?.id ?? null; + if (!manualSelection || !sessions.some((session) => session.id === selectedId)) { + selectedId = newest; + if (selectedId !== previous) { + selectedTaskId = null; + lastDetailKey = ''; + missingFinalRetries = 0; + lastSignature = ''; + currentFlow = null; + for (const id of ['total', 'running', 'done', 'failed']) dom[id].textContent = '—'; + dom.flow.replaceChildren(node('div', 'empty', '正在读取所选会话…')); + dom.detail.replaceChildren(node('div', 'empty', '正在读取子任务…')); + } + manualSelection = false; + } + dom['session-select'].replaceChildren(...sessions.map((session) => { + const option = node('option', '', `${session.title}${session.taskCount ? ` · ${session.taskCount} 项子任务` : ''}`); + option.value = session.id; + option.title = session.id; + return option; + })); + dom['session-select'].disabled = sessions.length === 0; + if (selectedId) dom['session-select'].value = selectedId; + dom['follow-latest'].hidden = !manualSelection; + dom['load-more-sessions'].hidden = !hasMore || sessionLimit >= 10000; + dom['selection-note'].textContent = manualSelection + ? `手动选择的本机会话 · 列出 ${sessions.length} 条${hasMore ? sessionLimit >= 10000 ? ',已达展示上限' : ',还有更多' : ''}` + : `自动跟随最近更新的本机会话(推断,可能不是当前会话)· 列出 ${sessions.length} 条${hasMore ? sessionLimit >= 10000 ? ',已达展示上限' : ',还有更多' : ''}`; + showError(''); + if (!selectedId) { + dom.flow.replaceChildren(node('div', 'empty', '暂无可读取的本机会话。')); + dom.detail.replaceChildren(node('div', 'empty', '暂无子任务。')); + } else if (previous !== selectedId) await loadFlow(); + } catch (error) { + if (sequence === sessionSequence) showError(error.message); + } +} + +function addFact(list, label, value) { + list.append(node('dt', '', label), node('dd', '', value)); +} + +function addTextSection(container, title, text) { + const section = node('section', 'detail-section'); + const head = node('div', 'head'); + head.append(node('h4', '', title)); + if (text) { + const button = node('button', 'copy', '复制'); + button.type = 'button'; + button.addEventListener('click', async () => { + try { await navigator.clipboard.writeText(text); button.textContent = '已复制'; } + catch { button.textContent = '复制失败'; } + }); + head.append(button); + } + section.append(head); + section.append(text ? node('pre', '', text) : node('p', 'none', '当前记录中没有可展示的内容。')); + container.append(section); +} + +function renderDetail(task) { + const detail = dom.detail; + detail.replaceChildren(); + if (!task) { + detail.append(node('div', 'empty', '选择一项子任务')); + return; + } + detail.append(node('h3', '', task.name)); + detail.append(node('div', 'subline', `${task.agent} · ${statusNames[task.status] || task.status} · ${task.mode}`)); + const facts = node('dl', 'facts'); + addFact(facts, '创建', time(task.createdAt)); + addFact(facts, '开始运行', time(task.startedAt)); + addFact(facts, '结束', time(task.endedAt)); + addFact(facts, '耗时', duration(task.startedAt && task.endedAt ? task.endedAt - task.startedAt : null)); + const parentState = task.parentState === 'matched' ? '已在父会话历史中找到回传' + : task.parentState === 'unreadable' ? '父会话历史暂不可读,无法核实' + : task.parentState === 'unverified' ? '任务标记已交付;当前历史未找到回传(可能已压缩)' + : '尚未交付'; + addFact(facts, '父会话结果', parentState); + addFact(facts, '子会话', task.childId ? `${task.childId.slice(0, 12)}…` : '尚未建立'); + detail.append(facts); + if (task.warning || task.historyWarning) detail.append(node('p', 'warning', task.warning || task.historyWarning)); + addTextSection(detail, '委派指令(父 Agent 发出的 task 参数)', task.instruction); + addTextSection(detail, '子 Agent 最终回复', task.finalText); + const section = node('section', 'detail-section'); + section.append(node('h4', '', `子 Agent 工具调用 · ${task.tools.length}`)); + const tools = node('div', 'tools'); + for (const name of task.tools) tools.append(node('span', 'tool-pill', name)); + section.append(task.tools.length ? tools : node('p', 'none', '尚无工具调用记录。')); + detail.append(section); +} + +async function loadDetail(task) { + if (!task) { renderDetail(null); return; } + const key = `${selectedId}:${task.id}:${task.status}:${task.endedAt}:${task.updatedAt}:${task.parentState}:${task.historyWarning}`; + if (key === lastDetailKey) return; + lastDetailKey = key; + const sequence = ++detailSequence; + dom.detail.replaceChildren(node('div', 'empty', '正在读取子会话详情…')); + try { + const detail = await getJson(`/api/task?id=${encodeURIComponent(selectedId)}&task=${encodeURIComponent(task.id)}`); + if (sequence === detailSequence && selectedTaskId === task.id) { + missingFinalRetries = detail.childId && !detail.finalText ? missingFinalRetries + 1 : 0; + renderDetail(detail); + } + } catch (error) { + if (sequence === detailSequence) { + lastDetailKey = ''; + dom.detail.replaceChildren(node('div', 'empty', error.message)); + } + } +} + +function renderFlow(flow) { + const tasks = flow.tasks; + dom.total.textContent = tasks.length; + dom.running.textContent = tasks.filter((task) => ['queued', 'running', 'stopping'].includes(task.status)).length; + dom.done.textContent = tasks.filter((task) => task.status === 'succeeded').length; + dom.failed.textContent = tasks.filter((task) => ['failed', 'canceled', 'lost'].includes(task.status)).length; + const notes = []; + for (const group of flow.analysis?.sameTurnGroups ?? []) { + const mode = group.execution === 'serial' ? '运行区间没有重叠(实际串行)' + : group.execution === 'overlap' ? '运行区间有重叠' + : '尚有任务未结束,暂不判断并行关系'; + notes.push(`同一轮委派的 ${group.count} 项子任务:${mode}${group.wallMs === undefined ? '' : `;跨度 ${duration(group.wallMs)}`}`); + } + if (tasks.length) { + const uncertain = tasks.filter((task) => ['unverified', 'unreadable'].includes(task.parentState)).length; + notes.push(`${flow.analysis?.parentResults ?? 0}/${tasks.length} 项回传可在当前父会话历史中核实${uncertain ? `;${uncertain} 项暂无法核实(可能已压缩或读取失败)` : ''}。匹配不代表父 Agent 已采用。`); + } + dom.observation.replaceChildren(...(notes.length ? notes : ['暂无可分析的子任务。']).map((note) => node('p', '', note))); + const root = node('div', 'root-card'); + root.append(node('span', 'label', '父 Agent · 当前观察会话')); + root.append(node('strong', '', flow.session.title)); + root.append(node('small', '', `更新于 ${time(flow.session.updatedAt)}`)); + dom.flow.replaceChildren(root); + if (tasks.length === 0) { + dom.flow.append(node('div', 'empty', '这条会话尚无原生 task 子 Agent 记录。\n用户可以照常在 MiniMax Code 对话,插件会在打开期间刷新。')); + renderDetail(null); + return; + } + const list = node('div', 'task-list'); + const now = Date.now(); + const first = Math.min(...tasks.map((task) => task.createdAt || task.startedAt || now)); + const last = Math.max(...tasks.map((task) => task.endedAt || (task.startedAt && ['running', 'stopping'].includes(task.status) ? now : task.createdAt || first))); + const span = Math.max(1, last - first); + for (const task of tasks) { + const button = node('button', `task-card ${task.status}${task.depth ? ' nested' : ''}${task.id === selectedTaskId ? ' selected' : ''}`); + button.type = 'button'; + button.style.setProperty('--depth', String(task.depth || 0)); + const top = node('div', 'task-top'); + top.append(node('span', 'task-title', task.name), node('span', `status ${task.status}`, statusNames[task.status] || task.status)); + const meta = node('div', 'task-meta'); + meta.append(node('span', '', `${task.agent} · ${task.mode}${task.parentTaskId ? ' · 子任务' : ''}`), node('span', '', task.startedAt ? `${time(task.startedAt)} – ${time(task.endedAt)}` : `创建 ${time(task.createdAt)} · 尚未开始`)); + const rail = node('div', 'rail'); + const bar = node('span'); + if (task.startedAt && (task.endedAt || ['running', 'stopping'].includes(task.status))) { + bar.style.marginLeft = `${Math.max(0, Math.min(100, (task.startedAt - first) / span * 100))}%`; + bar.style.width = `${Math.max(1, Math.min(100, ((task.endedAt || now) - task.startedAt) / span * 100))}%`; + } else bar.hidden = true; + rail.append(bar); + const handoff = task.parentState === 'matched' ? '回传已在父会话中找到' + : task.parentState === 'unverified' ? '已交付;当前历史无法核实回传' + : task.parentState === 'unreadable' ? '父会话历史不可读' : '等待结果'; + button.append(top, meta, rail, node('div', 'flow-arrow', handoff)); + button.addEventListener('click', () => { + selectedTaskId = task.id; + renderFlow(flow); + }); + list.append(button); + } + dom.flow.append(list); + if (!tasks.some((task) => task.id === selectedTaskId)) selectedTaskId = tasks[0].id; + void loadDetail(tasks.find((task) => task.id === selectedTaskId)); +} + +async function loadFlow() { + if (!selectedId) return; + const id = selectedId; + const sequence = ++requestSequence; + try { + const flow = await getJson(`/api/flow?id=${encodeURIComponent(id)}`); + if (sequence !== requestSequence || id !== selectedId) return; + currentFlow = flow; + const signature = JSON.stringify({ session: flow.session, tasks: flow.tasks }); + if (signature !== lastSignature || flow.tasks.some((task) => ['running', 'stopping'].includes(task.status))) { + lastSignature = signature; + renderFlow(flow); + } + dom['refresh-state'].textContent = `已更新 ${time(flow.observedAt)}`; + showError(''); + } catch (error) { + if (sequence !== requestSequence) return; + dom['refresh-state'].textContent = '等待重试'; + showError(error.message); + } +} + +dom['session-select'].addEventListener('change', () => { + selectedId = dom['session-select'].value; + selectedTaskId = null; + lastDetailKey = ''; + missingFinalRetries = 0; + manualSelection = true; + dom['follow-latest'].hidden = false; + lastSignature = ''; + dom['selection-note'].textContent = '手动选择的本机会话'; + void loadFlow(); +}); +dom['follow-latest'].addEventListener('click', () => { + manualSelection = false; + void loadSessions(); +}); +dom['refresh-button'].addEventListener('click', () => { + lastSignature = ''; + lastDetailKey = ''; + missingFinalRetries = 0; + void loadSessions().then(loadFlow); +}); +dom['load-more-sessions'].addEventListener('click', () => { + sessionLimit = Math.min(10000, sessionLimit + 40); + void loadSessions(); +}); +void loadSessions(); +setInterval(() => { if (document.visibilityState === 'visible') void loadFlow(); }, 3000); +setInterval(() => { if (document.visibilityState === 'visible') void loadSessions(); }, 15000); +setInterval(() => { + if (document.visibilityState !== 'visible' || !currentFlow) return; + const task = currentFlow.tasks.find((item) => item.id === selectedTaskId); + if (task && (['queued', 'running', 'stopping'].includes(task.status) || (missingFinalRetries > 0 && missingFinalRetries < 6))) { + lastDetailKey = ''; + void loadDetail(task); + } +}, 10000); diff --git a/plugins/huixin615/agent-flow/miniapp/client/client-api.ts b/plugins/huixin615/agent-flow/miniapp/client/client-api.ts new file mode 100644 index 00000000..2ce7ab69 --- /dev/null +++ b/plugins/huixin615/agent-flow/miniapp/client/client-api.ts @@ -0,0 +1,387 @@ +/** + * Copyable Mini App Client declarations for TypeScript and JavaScript/JSDoc authoring. + * No Host implementation or runtime import: Electron preload provides window.mcode. + * Keep the .ts filename because Electron dependency packaging filters out .d.ts assets. + * Node start(context) uses miniapp-api.ts instead. See docs/client.md for usage and errors. + */ +export interface MiniAppMessageAPI { + /** + * Ask the user to append plain text to this Mini App's conversation input. + * Accepts at most 100,000 UTF-16 code units and preserves spaces, tabs, and newlines. + * An empty string still requires confirmation and leaves the draft unchanged. + * Resolves after the confirmed append; rejects on cancellation or failure. + * Does not send a message or insert, edit, or delete sent chat history. + */ + append(text: string): Promise; +} + +export type MiniAppAgentQuestionMode = 'questionnaire' | 'feature-enable' | 'plan'; + +export interface MiniAppAgentQuestionOption { + readonly id: string; + readonly label: string; + readonly description?: string; +} + +export interface MiniAppAgentQuestionStep { + readonly id: string; + readonly header?: string; + readonly question: string; + readonly description?: string; + readonly selectionMode: 'single' | 'multiple'; + readonly options: readonly MiniAppAgentQuestionOption[]; + /** Free-text "other" is always accepted; see `MiniAppAgentQuestionAnswer.otherText`. */ + readonly allowOther: true; +} + +/** Same questionnaire the Host chat renders; render every step and answer each one. */ +export interface MiniAppAgentQuestionnaire { + readonly schemaVersion: 2; + readonly id: string; + readonly title?: string; + readonly steps: readonly MiniAppAgentQuestionStep[]; + readonly mode?: MiniAppAgentQuestionMode; + /** Plan review Markdown and other mode-specific data. */ + readonly modePayload?: { readonly [key: string]: unknown }; + /** Unix ms; the request is withdrawn after this. */ + readonly expiresAt?: number; +} + +export interface MiniAppAgentQuestionAnswer { + readonly stepId: string; + readonly selectedOptionIds: readonly string[]; + readonly selectedOther: boolean; + readonly otherText?: string; + readonly skipped?: boolean; +} + +/** A model as the Host's composer selects it; take the values from `models()`. */ +export interface MiniAppAgentModelSelection { + readonly providerId: string; + readonly modelId: string; + /** `''` means thinking is off; it is preserved, distinct from an absent `variant`. */ + readonly variant?: string; + readonly thinking?: { readonly effort: string }; +} + +/** One model this user can use. */ +export interface MiniAppAgentModelOption { + readonly providerId: string; + readonly modelId: string; + readonly displayName: string; + readonly variants?: readonly string[]; + readonly thinking?: { readonly efforts: readonly string[]; readonly default?: string }; +} + +export interface MiniAppAgentModels { + readonly models: readonly MiniAppAgentModelOption[]; + /** What a session without a host chat model starts with. */ + readonly default: MiniAppAgentModelSelection; + /** The host chat's current model (the default when the chat has none); only when opened from a chat. */ + readonly host?: MiniAppAgentModelSelection; +} + +export interface MiniAppAgentAgentOption { + /** Canonical name; what `agent` takes. */ + readonly name: string; + readonly displayName: string; + /** The default agent. */ + readonly primary: boolean; +} + +export interface MiniAppAgentAgents { + readonly agents: readonly MiniAppAgentAgentOption[]; +} + +/** Final outcome of one Agent turn. */ +export interface MiniAppAgentRunResult { + /** Pass back in `run({ sessionId })` to continue the same conversation. */ + readonly sessionId: string; + /** Absent when no turn ran: an `answer` accepted without starting a continuation. */ + readonly turnId?: string; + /** The model the turn was dispatched with; absent when no turn ran. */ + readonly model?: MiniAppAgentModelSelection; + /** `aborted` only when the Host confirmed the abort; an unconfirmed cancel rejects with `result_unknown`. */ + readonly status: 'finished' | 'error' | 'aborted'; + /** Final assistant reply; absent when the turn produced none or did not finish. */ + readonly text?: string; + readonly error?: { readonly code: string; readonly message: string }; + /** Set when the turn ended waiting on a questionnaire; render `request` and continue with `agent.answer`. */ + readonly pendingQuestion?: { + readonly requestId: string; + readonly mode: MiniAppAgentQuestionMode; + readonly request: MiniAppAgentQuestionnaire; + }; +} + +/** + * Progress of one run. `turnId` matches the result's, except that a `permission.requested` raised + * by a delegated child session carries that child's own `sessionId` and `turnId`; `reply` with + * those, and do not filter events by the root turn. Ignore unknown `type` values: new event + * types may be added without notice. + * - `run.queued`: all run slots are taken and the call waits at 1-based `position`; sent once, + * before the turn starts, so it has no `turnId` and names a session only when continuing one. + * - `turn.started`: the Agent began the turn; `model` is the model it was dispatched with. + * - `text.delta`: a piece of the reply as it streams. + * - `message.committed`: one complete assistant message (a turn can have several). + * - `tool.started` / `tool.finished`: a tool call by name; `ok` is false when it failed. + * - `permission.requested`: a tool call waits for approval; decide it with `agent.reply`. + * - `question.requested`: the turn paused on a questionnaire; continue with `agent.answer`. + * - `turn.finished`: always last; same `status`, `text`, and `error` as the run result. + * Every event except `run.queued` names its `sessionId`, so `reply` works before the `run` that + * created the session has returned. `answer` does not: call it after the paused `run` resolves, + * or it rejects as busy. + */ +export type MiniAppAgentRunEvent = + | { readonly type: 'run.queued'; readonly sessionId?: string; readonly position: number } + | { + readonly type: 'turn.started'; + readonly sessionId: string; + readonly turnId: string; + readonly model: MiniAppAgentModelSelection; + } + | { + readonly type: 'text.delta'; + readonly sessionId: string; + readonly turnId: string; + readonly text: string; + } + | { + readonly type: 'message.committed'; + readonly sessionId: string; + readonly turnId: string; + readonly text: string; + } + | { + readonly type: 'tool.started'; + readonly sessionId: string; + readonly turnId: string; + readonly name: string; + } + | { + readonly type: 'tool.finished'; + readonly sessionId: string; + readonly turnId: string; + readonly name: string; + readonly ok: boolean; + } + | { + readonly type: 'permission.requested'; + readonly sessionId: string; + /** Absent when the runtime did not attribute the ask to a turn. */ + readonly turnId?: string; + readonly requestId: string; + readonly toolName: string; + readonly toolInput?: string; + readonly reason: string; + } + | { + readonly type: 'question.requested'; + readonly sessionId: string; + readonly turnId?: string; + readonly requestId: string; + readonly mode: MiniAppAgentQuestionMode; + readonly request: MiniAppAgentQuestionnaire; + } + | { + readonly type: 'turn.finished'; + readonly sessionId: string; + readonly turnId: string; + readonly status: 'finished' | 'error' | 'aborted'; + readonly text?: string; + readonly error?: { readonly code: string; readonly message: string }; + }; + +export interface MiniAppAgentRunInput { + /** Plain text, at most 100,000 UTF-16 code units. */ + readonly text: string; + /** Continue a session this Mini App created earlier; omit to start a new one. */ + readonly sessionId?: string; + /** + * Switch the session to this model before the turn (validated like the composer validates a new + * session's model: it must be one `models()` lists; unusable is `invalid_request`, missing BYOK + * credentials end the turn with `status: 'error'`); the session keeps it. Omit to use the session's current model. + */ + readonly model?: MiniAppAgentModelSelection; + /** + * Only when starting a session (no `sessionId`): an agent `name` from `agents()`. A session + * never changes its agent, so passing it with `sessionId` is `invalid_request`. + */ + readonly agent?: string; + /** Your id for `cancel(requestId)`; 1-128 characters, unique among this page's active runs. */ + readonly requestId?: string; +} + +export interface MiniAppAgentReplyInput { + readonly sessionId: string; + /** `requestId` of the `permission.requested` event. */ + readonly requestId: string; + /** No `allowAlways`: a Mini App never writes the user's global rules. */ + readonly decision: 'allowOnce' | 'deny'; +} + +export interface MiniAppAgentAnswerInput { + readonly sessionId: string; + /** `requestId` of the `question.requested` event or of `result.pendingQuestion`. */ + readonly questionId: string; + /** One answer per questionnaire step. */ + readonly answers: readonly MiniAppAgentQuestionAnswer[]; + /** Same as `MiniAppAgentRunInput.model`, applied before the continuation turn. */ + readonly model?: MiniAppAgentModelSelection; + /** Your id for `cancel(requestId)`, as in `run`. */ + readonly requestId?: string; +} + +export interface MiniAppAgentRunOptions { + readonly onEvent?: (event: MiniAppAgentRunEvent) => void; +} + +/** + * Absent on older Hosts. Calls reject with `not_permitted` unless the manifest declares + * `agentAccess: ["session.run"]`. A new session uses the Agent of the chat this page is open in; + * a page without a host chat uses the primary Agent unless `run({ agent })` / `create({ agent })` + * names one from `agents()`. Continuing a session keeps its Agent. + * The Agent works in the host chat's project directory when it has one, otherwise in this Mini + * App's own Agent directory; a continued session keeps its directory. A new session takes the + * model and thinking level of the host chat (the runtime default when the chat has none) and keeps + * them; `run` / `answer` with `model` switch the session's model first. + * It runs one turn with that Agent's full capabilities (files, shell, Skills, MCP including this + * Mini App's own), and resolves when it ends. + * + * Interaction is yours to render: handle `permission.requested` with `reply` and + * `question.requested` with `answer`. A permission request waits only while this run passed + * `onEvent` or the session is open in a chat side panel through `open`; otherwise it is denied + * and the model is told why. A run with `onEvent` that does not handle the request waits until + * `cancel` or the page closes; there is no timeout. Up to `agentConcurrency` runs (default 4) per + * Mini App run at once, one per session; further `run` / `answer` calls wait in order (up to four + * times that many, see `run.queued`), and `cancel` on a waiting call rejects it with `cancelled`. + * `busy` means the session already has a run in progress or the wait queue is full. + * Failures reject with an `Error` whose `message` is `invalid_request`, `not_permitted`, `busy`, + * `session_not_found`, `runtime_unavailable`, `cancelled`, `result_unknown`, or + * `request_not_found`. `runtime_unavailable` means nothing was started; `result_unknown` means + * the turn may have run (or ended without the Host knowing whether a question is pending), so + * never repeat it automatically. + */ +export interface MiniAppAgentAPI { + run( + input: MiniAppAgentRunInput, + options?: MiniAppAgentRunOptions, + ): Promise; + /** Decide a pending permission request; `request_not_found` when it is unknown or settled. */ + reply(input: MiniAppAgentReplyInput): Promise; + /** + * Answer a questionnaire; the continuation turn is observed like `run`. Rejects with + * `result_unknown` when the answer was recorded but the continuation turn was not observed. + */ + answer( + input: MiniAppAgentAnswerInput, + options?: MiniAppAgentRunOptions, + ): Promise; + /** Ask the Host to stop an active run or answer; unknown or finished ids are ignored. */ + cancel(requestId: string): void; + /** + * Show one of this Mini App's sessions as a tab in the side panel of the chat the + * Mini App is open in, where the user can read the conversation and answer + * permission and question cards themselves. Permission requests wait for the tab only + * while it stays open; once the user closes it, handle `permission.requested` yourself + * or call `open` again. Any session this Mini App owns can be opened, whichever chat + * created it; `session_not_found` otherwise. + */ + open(sessionId: string): Promise; + /** + * Returns the model catalog, runtime `default` and optional chat `host`. Pass `default` / `host` + * directly as `model` to `run` / `answer`. For a catalog entry, extract `providerId` / `modelId`, + * add a selected `variant` if needed and preserve `variant: ''`. If thinking is supported, + * select an effort from `thinking.efforts` and pass `thinking: { effort }`. + */ + models(): Promise; + /** The agents the user has configured; `agent` of `create` / a first `run` must be one of them. */ + agents(): Promise; + /** + * Create a session bound to this chat (agent, project directory, model) without running a + * turn, and return its `sessionId`; continue it with `run({ sessionId })` here or hand it to + * Node code. `agent` picks the session's agent from `agents()`; a session never changes it. + * Takes no run slot. If the Host's 60 s deadline passes it rejects with `runtime_unavailable` + * although the session may already exist. + */ + create(input?: { readonly agent?: string }): Promise<{ readonly sessionId: string }>; +} + +export interface MiniAppClientAPI { + readonly message: MiniAppMessageAPI; + readonly agent?: MiniAppAgentAPI; + /** + * Proposed: shape may still change. Feature-detect with + * `window.mcode?.miniapp?.window` before use. + */ + readonly window?: MiniAppWindowHandle; +} + +/** Live state of the popped-out window: current position, size, and flags — not the manifest declaration. */ +export interface MiniAppWindowOptions { + readonly width: number; + readonly height: number; + readonly minWidth: number; + readonly minHeight: number; + readonly x: number; + readonly y: number; + readonly visible: boolean; + readonly resizable: boolean; + readonly frame: boolean; + readonly transparent: boolean; + readonly alwaysOnTop: boolean; + readonly clickThrough: boolean; + readonly skipTaskbar: boolean; +} + +export type MiniAppWindowState = + | { readonly mode: 'inline' } + | { readonly mode: 'window'; readonly window: MiniAppWindowOptions }; + +/** Every field is optional; omitted fields are left unchanged. */ +export type MiniAppWindowPatch = Partial<{ + mode: 'inline' | 'window'; + width: number; + height: number; + x: number; + y: number; + visible: boolean; + resizable: boolean; + alwaysOnTop: boolean; + clickThrough: boolean; + skipTaskbar: boolean; +}>; + +export interface MiniAppWindowDisposable { + dispose(): void; +} + +/** + * Page-side handle for this Mini App's own window (proposed). Separate from + * `message`; the Host injects both under the same `mcode.miniapp` namespace. + */ +export interface MiniAppWindowHandle { + /** Opaque and stable across pop-out and return; never a Session or launch identifier. */ + readonly id: string; + getState(): Promise; + /** + * `update({ mode: ... })` or `focus()` require the call to originate from a + * user gesture (`navigator.userActivation.isActive`); otherwise they reject + * with `not_permitted` before any Host round trip. + */ + update(patch: MiniAppWindowPatch): Promise; + focus(): Promise; + /** Returns a disposable; call `dispose()` to stop receiving updates. */ + onDidChangeState(listener: (state: MiniAppWindowState) => void): MiniAppWindowDisposable; +} + +export interface McodeClientAPI { + readonly miniapp: MiniAppClientAPI; +} + +declare global { + interface Window { + /** Absent outside a supported managed Mini App main page. Check before use. */ + readonly mcode?: McodeClientAPI; + } +} diff --git a/plugins/huixin615/agent-flow/miniapp/client/index.html b/plugins/huixin615/agent-flow/miniapp/client/index.html new file mode 100644 index 00000000..d03f7d3d --- /dev/null +++ b/plugins/huixin615/agent-flow/miniapp/client/index.html @@ -0,0 +1,48 @@ + + + + + + + Agent 协作透视 + + + + +
+ +
Agent 协作透视原生子 Agent · 本机只读观测
+ ● 本机数据 +
+
+
+
观察会话
+

自动跟随最近更新的本机会话(推断)

+ +
+
读取中…
+
+ +
+
子任务—
+
进行中(含排队)—
+
已完成—
+
失败或中断—
+
+
+ 执行观察
正在读取任务时间和结果记录…
+
+
+
+
EXECUTION FLOW

协作时间线

点击任务查看证据
+
正在读取本机会话…
+
+ +
+

状态来自本机保存的任务与会话记录;“已交付”是任务记录状态,父会话回传须另行核实。页面打开期间定期刷新。

+
+ + diff --git a/plugins/huixin615/agent-flow/miniapp/miniapp.json b/plugins/huixin615/agent-flow/miniapp/miniapp.json new file mode 100644 index 00000000..d898d74e --- /dev/null +++ b/plugins/huixin615/agent-flow/miniapp/miniapp.json @@ -0,0 +1,16 @@ +{ + "schemaVersion": 1, + "artifacts": { + "client": ["./miniapp/client"], + "node": ["./miniapp/node"] + }, + "runtime": { + "kind": "process", + "entry": "./miniapp/node/server.mjs", + "lifecycle": "on-demand" + }, + "surface": { + "path": "/dashboard" + }, + "mcpEndpoints": [] +} diff --git a/plugins/huixin615/agent-flow/miniapp/node/data.mjs b/plugins/huixin615/agent-flow/miniapp/node/data.mjs new file mode 100644 index 00000000..49772e6b --- /dev/null +++ b/plugins/huixin615/agent-flow/miniapp/node/data.mjs @@ -0,0 +1,366 @@ +// Read-only adapter for the local runtime layout observed in MiniMax Code 3.1.4. +import { readFile, realpath, stat } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join, relative, resolve, sep } from 'node:path'; + +const SESSION_ID = /^mvs_[a-zA-Z0-9]+$/; +const TASK_ID = /^bg_[a-zA-Z0-9-]+$/; +const RELATIVE_DIR = /^20\d{2}\/\d{2}\/\d{2}\/[a-zA-Z0-9_-]+-session_[a-zA-Z0-9_-]+$/; +const MAX_HISTORY_BYTES = 8 * 1024 * 1024; +const MAX_TREE_TASKS = 2000; +const MAX_TREE_DEPTH = 8; +const HISTORY_CACHE_SIZE = 8; +const historyCache = new Map(); +const parentEvidenceCache = new WeakMap(); + +function timestamp(value) { + const number = Number(value); + return Number.isFinite(number) && number > 0 ? number : null; +} + +export class DataError extends Error { + constructor(code, message) { + super(message); + this.code = code; + } +} + +export function dataHome(env = process.env) { + return typeof env.MAVIS_HOME === 'string' && env.MAVIS_HOME.trim() + ? resolve(env.MAVIS_HOME.trim()) + : join(homedir(), '.minimax'); +} + +async function withDb(home, use) { + let DatabaseSync; + try { + ({ DatabaseSync } = await import('node:sqlite')); + } catch { + throw new DataError('SQLITE_UNAVAILABLE', '当前 Node 运行时无法读取本机会话数据库。'); + } + let db; + try { + db = new DatabaseSync(join(home, 'v2', 'sqlite', 'runtime-state.sqlite'), { readOnly: true }); + return use(db); + } catch (error) { + if (error instanceof DataError) throw error; + throw new DataError('STORE_UNAVAILABLE', '本机会话数据库不可用或格式不兼容。'); + } finally { + db?.close(); + } +} + +function sessionRow(db, id) { + return db.prepare(` + SELECT session_id AS id, parent_session_id AS parentId, session_kind AS kind, + title, status, created_at_ms AS createdAt, updated_at_ms AS updatedAt, + history_relative_dir AS historyDir + FROM local_runtime_sessions + WHERE session_id = ? + `).get(id); +} + +export async function listConversations(home = dataHome(), limit = 40, pinnedId = null) { + if (!Number.isInteger(limit) || limit < 1 || limit > 10000) { + throw new DataError('INVALID_LIMIT', '会话列表数量无效。'); + } + if (pinnedId !== null && !SESSION_ID.test(pinnedId)) { + throw new DataError('INVALID_SESSION', '会话 ID 格式无效。'); + } + const { rows, pinned } = await withDb(home, (db) => { + const rows = db.prepare(` + SELECT s.session_id AS id, s.title, s.status, + s.created_at_ms AS createdAt, s.updated_at_ms AS updatedAt, + (SELECT COUNT(*) FROM local_runtime_background_tasks t + WHERE t.kind = 'subagent' AND t.owner_session_id = s.session_id) AS taskCount + FROM local_runtime_sessions s + WHERE s.session_kind = 'conversation' AND s.parent_session_id IS NULL + ORDER BY s.updated_at_ms DESC + LIMIT ? + `).all(limit + 1); + let pinned = null; + if (pinnedId && !rows.some((row) => row.id === pinnedId)) { + const session = sessionRow(db, pinnedId); + if (session?.kind === 'conversation' && session.parentId === null) { + const count = db.prepare(`SELECT COUNT(*) AS count FROM local_runtime_background_tasks + WHERE kind = 'subagent' AND owner_session_id = ?`).get(pinnedId).count; + pinned = { id: session.id, title: session.title, status: session.status, + createdAt: session.createdAt, updatedAt: session.updatedAt, taskCount: count }; + } + } + return { rows, pinned }; + }); + const sessions = rows.slice(0, limit).filter((row) => SESSION_ID.test(row.id)) + .map((row) => ({ ...row, title: row.title || '未命名会话' })); + if (pinned && !sessions.some((row) => row.id === pinned.id)) { + sessions.push({ ...pinned, title: pinned.title || '未命名会话' }); + } + return { + sessions, + hasMore: rows.length > limit, + }; +} + +export function parseTaskRow(raw) { + let record; + try { record = JSON.parse(raw); } catch { return null; } + if (record?.kind !== 'subagent' || !TASK_ID.test(record.taskId) || !SESSION_ID.test(record.ownerSessionId)) return null; + const meta = record.metadata ?? {}; + const childId = typeof meta.childSessionId === 'string' && SESSION_ID.test(meta.childSessionId) ? meta.childSessionId : null; + const task = { + id: record.taskId, + ownerId: record.ownerSessionId, + childId, + parentTurnId: typeof meta.parentTurnId === 'string' ? meta.parentTurnId : null, + toolCallId: typeof record.toolCallId === 'string' ? record.toolCallId : null, + name: typeof record.description === 'string' ? record.description : '未命名委派', + agent: typeof meta.agentName === 'string' ? meta.agentName : '未知 Agent', + mode: typeof meta.executionMode === 'string' ? meta.executionMode : '未知', + status: typeof record.status === 'string' ? record.status : 'unknown', + createdAt: timestamp(record.createdAt), + startedAt: timestamp(record.startedAt), + endedAt: timestamp(record.endedAt), + deliveredAt: timestamp(record.deliveredAt), + updatedAt: timestamp(record.updatedAt), + }; + if (typeof meta.parentSessionId === 'string' && meta.parentSessionId !== task.ownerId) { + task.warning = '任务记录中的父会话 ID 不一致'; + task.childId = null; + } + return task; +} + +function loadTaskTree(db, rootId) { + const root = sessionRow(db, rootId); + if (!root || root.kind !== 'conversation' || root.parentId !== null) { + throw new DataError('SESSION_NOT_FOUND', '没有找到这条本机会话。'); + } + const sessions = new Map([[rootId, root]]); + const tasks = []; + const seen = new Set([rootId]); + const parentTasks = new Map(); + let frontier = [rootId]; + const byOwner = db.prepare(`SELECT record_json FROM local_runtime_background_tasks + WHERE kind = 'subagent' AND owner_session_id = ? ORDER BY created_at_ms ASC LIMIT ?`); + for (let depth = 0; frontier.length; depth += 1) { + const next = []; + for (const ownerId of frontier) { + const rows = byOwner.all(ownerId, MAX_TREE_TASKS + 1); + if (depth === MAX_TREE_DEPTH && rows.length) { + throw new DataError('TASK_DEPTH_LIMIT', '子 Agent 嵌套超过当前支持的层数,无法完整展示。'); + } + for (const row of rows) { + const task = parseTaskRow(row.record_json); + if (!task || task.ownerId !== ownerId) { + throw new DataError('TASK_RECORD_INVALID', '子任务记录与所属会话不一致或格式不兼容。'); + } + task.depth = depth; + task.parentTaskId = parentTasks.get(ownerId) ?? null; + tasks.push(task); + if (tasks.length > MAX_TREE_TASKS) { + throw new DataError('TASK_LIMIT', '当前会话的子任务过多,无法完整展示。'); + } + if (!task.childId) continue; + const child = sessions.get(task.childId) ?? sessionRow(db, task.childId); + if (!child) { + task.warning = '子会话索引尚未出现,等待刷新'; + continue; + } + if (child.kind !== 'task' || child.parentId !== ownerId) { + task.warning = '子会话的父会话与任务记录不一致'; + task.childId = null; + continue; + } + if (parentTasks.has(child.id) && parentTasks.get(child.id) !== task.id) { + task.warning = '同一子会话关联了多项任务,关系无法核实'; + task.childId = null; + continue; + } + sessions.set(child.id, child); + if (!seen.has(child.id)) { + seen.add(child.id); + parentTasks.set(child.id, task.id); + next.push(child.id); + } + } + } + frontier = next; + } + return { root, sessions, tasks: tasks.sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0)) }; +} + +async function historyRows(home, session) { + if (!session || !SESSION_ID.test(session.id) || typeof session.historyDir !== 'string' || !RELATIVE_DIR.test(session.historyDir)) { + throw new DataError('HISTORY_PATH_INVALID', '会话历史路径格式不符合已验证版本。'); + } + const token = session.historyDir.split('-session_').at(-1); + if (token !== Buffer.from(session.id, 'utf8').toString('base64url')) { + throw new DataError('HISTORY_PATH_INVALID', '会话历史目录与会话 ID 不一致。'); + } + const root = await realpath(resolve(home, 'v2', 'sessions')); + const file = resolve(root, session.historyDir, 'messages.jsonl'); + if (!file.startsWith(root + sep) || relative(root, file).startsWith('..')) { + throw new DataError('HISTORY_PATH_INVALID', '会话历史路径超出本机会话目录。'); + } + const actual = await realpath(file); + if (!actual.startsWith(root + sep)) throw new DataError('HISTORY_PATH_INVALID', '会话历史文件指向目录外。'); + if (actual !== file) throw new DataError('HISTORY_PATH_INVALID', '会话历史路径包含未验证的符号链接。'); + const info = await stat(actual); + if (info.size > MAX_HISTORY_BYTES) throw new DataError('HISTORY_TOO_LARGE', '会话历史较大,暂不加载消息详情。'); + const cached = historyCache.get(actual); + if (cached?.size === info.size && cached?.mtimeMs === info.mtimeMs) { + historyCache.delete(actual); + historyCache.set(actual, cached); + return cached.rows; + } + const raw = await readFile(actual, 'utf8'); + const lines = raw.split('\n').filter(Boolean); + if (lines.length > 4000) throw new DataError('HISTORY_TOO_LARGE', '会话消息过多,暂不加载详情。'); + const rows = lines.map((line) => { + try { return JSON.parse(line); } + catch { throw new DataError('HISTORY_INVALID', '会话历史包含不完整或损坏的记录。'); } + }); + historyCache.delete(actual); + historyCache.set(actual, { size: info.size, mtimeMs: info.mtimeMs, rows }); + if (historyCache.size > HISTORY_CACHE_SIZE) historyCache.delete(historyCache.keys().next().value); + return rows; +} + +function plainText(message) { + if (!Array.isArray(message?.content)) return ''; + return message.content.filter((part) => part?.type === 'text' && typeof part.text === 'string') + .map((part) => part.text).join('\n'); +} + +export function extractTaskEvidence(parentRows, childRows, toolCallId) { + const { instruction, parentResult } = parentEvidenceByCall(parentRows).get(toolCallId) + ?? { instruction: '', parentResult: false }; + let finalText = ''; + const tools = []; + for (const row of childRows) { + const message = row?.message; + if (message?.role !== 'assistant') continue; + const calls = Array.isArray(message.content) + ? message.content.filter((part) => part?.type === 'toolCall' && typeof part.name === 'string') : []; + for (const call of calls) tools.push(call.name); + } + const last = childRows.at(-1)?.message; + if (last?.role === 'assistant' && !last.content?.some?.((part) => part?.type === 'toolCall')) { + finalText = plainText(last); + } + return { instruction, finalText, parentResult, tools }; +} + +function parentEvidenceByCall(rows) { + if (parentEvidenceCache.has(rows)) return parentEvidenceCache.get(rows); + const calls = new Map(); + for (const row of rows) { + const message = row?.message; + if (message?.role === 'assistant' && Array.isArray(message.content)) { + for (const part of message.content) { + if (part?.type === 'toolCall' && part.name === 'task' && typeof part.id === 'string') { + calls.set(part.id, { + instruction: typeof part.arguments?.prompt === 'string' ? part.arguments.prompt : '', + parentResult: calls.get(part.id)?.parentResult ?? false, + }); + } + } + } + if (message?.role === 'toolResult' && message.toolName === 'task' && typeof message.toolCallId === 'string') { + const entry = calls.get(message.toolCallId) ?? { instruction: '', parentResult: false }; + entry.parentResult = true; + calls.set(message.toolCallId, entry); + } + } + parentEvidenceCache.set(rows, calls); + return calls; +} + +export function analyzeExecution(tasks) { + const groups = new Map(); + for (const task of tasks) { + if (!task.parentTurnId) continue; + const key = `${task.ownerId}:${task.parentTurnId}`; + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(task); + } + const sameTurnGroups = []; + for (const group of groups.values()) { + if (group.length < 2) continue; + const intervals = group.map((task) => [task.startedAt, task.endedAt]); + const complete = intervals.every(([start, end]) => + Number.isFinite(start) && Number.isFinite(end) && start > 0 && end >= start); + if (!complete) { + sameTurnGroups.push({ count: group.length, execution: 'incomplete' }); + continue; + } + intervals.sort((a, b) => a[0] - b[0]); + let latestEnd = 0; + let overlap = false; + for (const [start, end] of intervals) { + if (start < latestEnd) overlap = true; + latestEnd = Math.max(latestEnd, end); + } + sameTurnGroups.push({ + count: group.length, + execution: overlap ? 'overlap' : 'serial', + wallMs: latestEnd - intervals[0][0], + }); + } + return { + sameTurnGroups, + parentResults: tasks.filter((task) => task.parentResult).length, + delivered: tasks.filter((task) => task.deliveredAt).length, + }; +} + +export async function readFlow(rootId, home = dataHome()) { + if (!SESSION_ID.test(rootId)) throw new DataError('INVALID_SESSION', '会话 ID 格式无效。'); + const { root, sessions, tasks } = await withDb(home, (db) => loadTaskTree(db, rootId)); + const parentEvidence = new Map(); + const historyError = new Map(); + for (const id of new Set(tasks.map((task) => task.ownerId))) { + try { parentEvidence.set(id, parentEvidenceByCall(await historyRows(home, sessions.get(id)))); } + catch (error) { historyError.set(id, error instanceof DataError ? error.message : '会话历史暂不可读。'); } + } + const enriched = tasks.map((task) => { + const evidence = parentEvidence.get(task.ownerId)?.get(task.toolCallId) ?? { instruction: '', parentResult: false }; + const parentState = evidence.parentResult ? 'matched' + : historyError.has(task.ownerId) ? 'unreadable' + : task.deliveredAt ? 'unverified' : 'pending'; + return { + ...task, + instruction: evidence.instruction, + parentResult: evidence.parentResult, + parentState, + historyWarning: historyError.get(task.ownerId) || null, + }; + }); + return { + session: { id: root.id, title: root.title || '未命名会话', updatedAt: root.updatedAt }, + observedAt: Date.now(), + tasks: enriched, + analysis: analyzeExecution(enriched), + }; +} + +export async function readTaskDetail(rootId, taskId, home = dataHome()) { + if (!TASK_ID.test(taskId)) throw new DataError('INVALID_TASK', '任务 ID 格式无效。'); + const flow = await readFlow(rootId, home); + const task = flow.tasks.find((item) => item.id === taskId); + if (!task) throw new DataError('TASK_NOT_FOUND', '所选会话中没有这项子任务。'); + if (!task.childId) return { ...task, finalText: '', tools: [] }; + const child = await withDb(home, (db) => db.prepare(`SELECT session_id AS id, + parent_session_id AS parentId, session_kind AS kind, history_relative_dir AS historyDir + FROM local_runtime_sessions WHERE session_id = ?`).get(task.childId)); + if (!child || child.kind !== 'task' || child.parentId !== task.ownerId) { + return { ...task, finalText: '', tools: [], warning: '子会话记录暂不可用或关联不一致' }; + } + try { + const rows = await historyRows(home, child); + const evidence = extractTaskEvidence([], rows, null); + return { ...task, finalText: evidence.finalText, tools: evidence.tools }; + } catch (error) { + return { ...task, finalText: '', tools: [], historyWarning: error instanceof DataError ? error.message : '子会话历史暂不可读。' }; + } +} diff --git a/plugins/huixin615/agent-flow/miniapp/node/miniapp-api.ts b/plugins/huixin615/agent-flow/miniapp/node/miniapp-api.ts new file mode 100644 index 00000000..f5d8b7a4 --- /dev/null +++ b/plugins/huixin615/agent-flow/miniapp/node/miniapp-api.ts @@ -0,0 +1,397 @@ +/** + * Agent-facing Mini App runtime authoring declarations. + * + * Copy this file into a generated plugin for type checking. It contains no Host implementation; + * the Host injects runtime values through start(context). + * Keep the .ts filename: Electron packaging excludes .d.ts files from dependency assets. + */ +export type JsonPrimitive = null | boolean | number | string; +export type JsonValue = JsonPrimitive | JsonObject | readonly JsonValue[]; +export type JsonObject = { readonly [key: string]: JsonValue }; + +declare const HOST_CONNECTOR_TOOL_REF: unique symbol; +export type HostConnectorToolRef = string & { + readonly [HOST_CONNECTOR_TOOL_REF]: 'HostConnectorToolRef'; +}; + +export interface HostConnectorTool { + readonly toolRef: HostConnectorToolRef; + readonly provider: string; + readonly name: string; + readonly description?: string; + readonly inputSchema: JsonValue; + readonly outputSchema?: JsonValue; +} + +export interface HostConnectorListResult { + readonly tools: readonly HostConnectorTool[]; + readonly partial: boolean; +} + +export interface HostConnectorCallOptions { + readonly signal?: AbortSignal; +} + +export interface HostConnectorCallResult { + readonly invocationId: string; + /** + * Raw provider result; it is not normalized by the Host and may be an object, array, or primitive. + * A single text-block array is one provider shape, not a global Host transport contract. + * Decode only a probe-observed envelope; preserve every other value, including direct strings. + */ + readonly value: JsonValue; +} + +export interface HostConnectorClient { + /** Candidate-safe inventory only; available before and after activation. */ + list(options?: HostConnectorCallOptions): Promise; + /** Activation-only business dispatch; call from request handling, never start(context). */ + call( + toolRef: HostConnectorToolRef, + arguments_: JsonObject, + options?: HostConnectorCallOptions, + ): Promise; +} + +export type HostConnectorErrorDisposition = + | 'not_dispatched' + | 'provider_reported' + | 'unknown_after_dispatch'; + +export type HostConnectorErrorCode = + | 'TOOL_REF_STALE' + | 'SERVICE_RESTARTED' + | 'REQUEST_CANCELLED' + | 'CONNECTOR_TIMEOUT' + | 'INVALID_ARGUMENTS' + | 'CONNECTOR_PROVIDER_ERROR' + | 'CONNECTOR_UNAVAILABLE' + | 'CONNECTOR_OUTCOME_UNKNOWN'; + +export interface HostConnectorError extends Error { + readonly code: HostConnectorErrorCode; + readonly disposition: HostConnectorErrorDisposition; + readonly retryable: boolean; + readonly invocationId?: string; + readonly diagnostic?: { + readonly issues: readonly { + readonly path: string; + readonly constraint: string; + readonly limit?: number; + }[]; + }; +} + +export interface MiniAppLogger { + debug(message: string, fields?: JsonObject): void; + info(message: string, fields?: JsonObject): void; + warn(message: string, fields?: JsonObject): void; + error(message: string, fields?: JsonObject): void; +} + +export type MiniAppAgentRunStatus = 'finished' | 'error' | 'aborted'; + +export type MiniAppAgentQuestionMode = 'questionnaire' | 'feature-enable' | 'plan'; + +export interface MiniAppAgentQuestionOption { + readonly id: string; + readonly label: string; + readonly description?: string; +} + +export interface MiniAppAgentQuestionStep { + readonly id: string; + readonly header?: string; + readonly question: string; + readonly description?: string; + readonly selectionMode: 'single' | 'multiple'; + readonly options: readonly MiniAppAgentQuestionOption[]; + /** Free-text "other" is always accepted; see `MiniAppAgentQuestionAnswer.otherText`. */ + readonly allowOther: true; +} + +/** Same questionnaire the Host chat renders; render every step and answer each one. */ +export interface MiniAppAgentQuestionnaire { + readonly schemaVersion: 2; + readonly id: string; + readonly title?: string; + readonly steps: readonly MiniAppAgentQuestionStep[]; + readonly mode?: MiniAppAgentQuestionMode; + /** Plan review Markdown and other mode-specific data. */ + readonly modePayload?: JsonObject; + /** Unix ms; the request is withdrawn after this. */ + readonly expiresAt?: number; +} + +export interface MiniAppAgentQuestionAnswer { + readonly stepId: string; + readonly selectedOptionIds: readonly string[]; + readonly selectedOther: boolean; + readonly otherText?: string; + readonly skipped?: boolean; +} + +/** A model as the Host's composer selects it; take the values from `models()`. */ +export interface MiniAppAgentModelSelection { + readonly providerId: string; + readonly modelId: string; + /** `''` means thinking is off; it is preserved, distinct from an absent `variant`. */ + readonly variant?: string; + readonly thinking?: { readonly effort: string }; +} + +/** One model this user can use. */ +export interface MiniAppAgentModelOption { + readonly providerId: string; + readonly modelId: string; + readonly displayName: string; + readonly variants?: readonly string[]; + readonly thinking?: { readonly efforts: readonly string[]; readonly default?: string }; +} + +export interface MiniAppAgentModels { + readonly models: readonly MiniAppAgentModelOption[]; + /** What a Node-created session starts with. */ + readonly default: MiniAppAgentModelSelection; +} + +export interface MiniAppAgentAgentOption { + /** Canonical name; what `agent` takes. */ + readonly name: string; + readonly displayName: string; + /** The default agent. */ + readonly primary: boolean; +} + +export interface MiniAppAgentAgents { + readonly agents: readonly MiniAppAgentAgentOption[]; +} + +export interface MiniAppAgentRunResult { + /** Pass back in `run({ sessionId })` to continue the same conversation. */ + readonly sessionId: string; + /** Absent when no turn ran: an `answer` accepted without starting a continuation. */ + readonly turnId?: string; + /** The model the turn was dispatched with; absent when no turn ran. */ + readonly model?: MiniAppAgentModelSelection; + /** `aborted` only when the Host confirmed the abort; an unconfirmed one rejects with `AGENT_OUTCOME_UNKNOWN`. */ + readonly status: MiniAppAgentRunStatus; + /** Final assistant reply; absent when the turn produced none or did not finish. */ + readonly text?: string; + readonly error?: { readonly code: string; readonly message: string }; + /** Set when the turn ended waiting on a questionnaire; render `request` and continue with `agent.answer`. */ + readonly pendingQuestion?: { + readonly requestId: string; + readonly mode: MiniAppAgentQuestionMode; + readonly request: MiniAppAgentQuestionnaire; + }; +} + +/** + * Progress of one run. `turnId` matches the result's, except that a `permission.requested` raised + * by a delegated child session carries that child's own `sessionId` and `turnId`; `reply` with + * those, and do not filter events by the root turn. Ignore unknown `type` values: new event + * types may be added without notice. + * - `run.queued`: all run slots are taken and the call waits at 1-based `position`; sent once, + * before the turn starts, so it has no `turnId` and names a session only when continuing one. + * - `turn.started`: the Agent began the turn; `model` is the model it was dispatched with. + * - `text.delta`: a piece of the reply as it streams. + * - `message.committed`: one complete assistant message (a turn can have several). + * - `tool.started` / `tool.finished`: a tool call by name; `ok` is false when it failed. + * - `turn.finished`: always last; same `status`, `text`, and `error` as the run result. + */ +/** + * Every event except `run.queued` names its session, so `reply` can be called from `onEvent` even + * before the `run` that created the session has returned. `answer` cannot: the paused `run` still holds the + * session's single-run slot while `question.requested` is delivered, so call `answer` only after + * that `run` resolves (an earlier call rejects with `BUSY`). Ignore event types you do not know. + */ +export type MiniAppAgentRunEvent = + | { readonly type: 'run.queued'; readonly sessionId?: string; readonly position: number } + | { + readonly type: 'turn.started'; + readonly sessionId: string; + readonly turnId: string; + readonly model: MiniAppAgentModelSelection; + } + | { + readonly type: 'text.delta'; + readonly sessionId: string; + readonly turnId: string; + readonly text: string; + } + | { + readonly type: 'message.committed'; + readonly sessionId: string; + readonly turnId: string; + readonly text: string; + } + | { + readonly type: 'tool.started'; + readonly sessionId: string; + readonly turnId: string; + readonly name: string; + } + | { + readonly type: 'tool.finished'; + readonly sessionId: string; + readonly turnId: string; + readonly name: string; + readonly ok: boolean; + } + /** A tool call is waiting for approval; decide it with `agent.reply`. */ + | { + readonly type: 'permission.requested'; + readonly sessionId: string; + /** Absent when the runtime did not attribute the ask to a turn. */ + readonly turnId?: string; + readonly requestId: string; + readonly toolName: string; + readonly toolInput?: string; + readonly reason: string; + } + /** The turn paused on a questionnaire; continue with `agent.answer`. */ + | { + readonly type: 'question.requested'; + readonly sessionId: string; + readonly turnId?: string; + readonly requestId: string; + readonly mode: MiniAppAgentQuestionMode; + readonly request: MiniAppAgentQuestionnaire; + } + | { + readonly type: 'turn.finished'; + readonly sessionId: string; + readonly turnId: string; + readonly status: MiniAppAgentRunStatus; + readonly text?: string; + readonly error?: { readonly code: string; readonly message: string }; + }; + +export interface MiniAppAgentRunOptions { + readonly signal?: AbortSignal; + readonly onEvent?: (event: MiniAppAgentRunEvent) => void; +} + +export type MiniAppAgentErrorCode = + | 'NOT_PERMITTED' + | 'BUSY' + | 'SESSION_NOT_FOUND' + | 'INVALID_ARGUMENTS' + | 'AGENT_UNAVAILABLE' + | 'REQUEST_CANCELLED' + | 'AGENT_OUTCOME_UNKNOWN' + | 'REQUEST_NOT_FOUND'; + +export interface MiniAppAgentError extends Error { + readonly code: MiniAppAgentErrorCode; + /** + * True when nothing was started and the same call may be retried later. + * `AGENT_OUTCOME_UNKNOWN` is never retryable: the turn may have run. + */ + readonly retryable: boolean; + /** Set on every `BUSY`: the session already has a run in flight, or the wait queue is full. */ + readonly reason?: 'session_busy' | 'queue_full'; +} + +/** + * Present only when the manifest declares `agentAccess: ["session.run"]`. + * A new Node session uses the primary Agent unless `agent` names one from `agents()`, and works in + * this Mini App's own Agent directory and on the runtime default model; + * continuing a session keeps its Agent, directory and model (unless `model` switches it), including + * one a page created using its host chat's Agent, project directory and model; a session never + * changes its Agent. Runs one turn with that Agent's full capabilities + * (files, shell, Skills, MCP including this Mini App's own), and resolves when the turn ends. + * + * Interaction is yours to render: pass `onEvent` and handle `permission.requested` with + * `reply` and `question.requested` with `answer`. A permission request waits only while + * someone can answer it: this run passed `onEvent`, or a page of this Mini App currently has + * the session open in a chat side panel via `open`. Otherwise it is denied (the model is told + * why), and a questionnaire ends the turn with `result.pendingQuestion`. A run waiting on a + * permission is released by `cancel`/abort, process exit or republish. Up to `agentConcurrency` + * runs (default 4) per Mini App run at once; further `run` / `answer` calls wait in order (up to + * four times that many, see `run.queued`), and aborting a waiting call rejects it with + * `REQUEST_CANCELLED`. + */ +export interface MiniAppAgentClient { + run( + input: { + readonly text: string; + readonly sessionId?: string; + /** + * Switch the session to this model before the turn (validated like the composer validates a + * new session's model: it must be one `models()` lists; unusable is `INVALID_ARGUMENTS`, + * missing BYOK credentials end the turn with `status: 'error'`); the session keeps it. Omit to use the session's current model. + */ + readonly model?: MiniAppAgentModelSelection; + /** + * Only when starting a session (no `sessionId`): an agent `name` from `agents()`. Passing it + * with `sessionId` is `INVALID_ARGUMENTS`. + */ + readonly agent?: string; + }, + options?: MiniAppAgentRunOptions, + ): Promise; + /** + * Decide a pending permission request. Never writes the user's global rules. Node replies + * have 16 separate pending slots; `run` / `answer` / `models` / `agents` / `create` and Connector + * requests share another 16. Filling that ordinary budget still permits replies. A full reply + * budget rejects with retryable `AGENT_UNAVAILABLE`. + */ + reply(input: { + readonly sessionId: string; + readonly requestId: string; + readonly decision: 'allowOnce' | 'deny'; + }): Promise; + /** + * Answer a questionnaire; the continuation turn is observed like `run`. Rejects with + * `AGENT_OUTCOME_UNKNOWN` when the answer was recorded but the continuation turn was not observed. + */ + answer( + input: { + readonly sessionId: string; + readonly requestId: string; + readonly answers: readonly MiniAppAgentQuestionAnswer[]; + /** Same as `run`'s `model`, applied before the continuation turn. */ + readonly model?: MiniAppAgentModelSelection; + }, + options?: MiniAppAgentRunOptions, + ): Promise; + /** + * Returns the model catalog and runtime `default`. Pass `default` directly as `model` to + * `run` / `answer`. For a catalog entry, extract `providerId` / `modelId`, add a selected `variant` + * if needed and preserve `variant: ''`. If thinking is supported, select an effort from + * `thinking.efforts` and pass `thinking: { effort }`. + */ + models(): Promise; + /** The agents the user has configured; `agent` of `create` / a first `run` must be one of them. */ + agents(): Promise; + /** + * Create a session (agent, this Mini App's Agent directory, the runtime default model) without + * running a turn, and return its `sessionId`; continue it with `run({ sessionId })`. A page's + * `create` binds the session to its chat instead, and its `sessionId` can be continued here. + * Takes no Host run slot but uses the ordinary Node request budget. `agent` picks the session's + * agent from `agents()`; a session never changes it. + */ + create(input?: { readonly agent?: string }): Promise<{ readonly sessionId: string }>; +} + +export interface MiniAppLifecycle { + dispose(): void | Promise; +} + +export interface MiniAppContext { + readonly pluginId: string; + readonly pluginRoot: string; + readonly dataDir: string; + readonly listen: Readonly<{ readonly host: '127.0.0.1'; readonly port: number }>; + readonly signal: AbortSignal; + readonly logger: MiniAppLogger; + readonly hostConnector?: HostConnectorClient; + readonly agent?: MiniAppAgentClient; +} + +export interface MiniAppModule { + /** Resolve only after the listener accepts connections and every route is installed. */ + start(context: MiniAppContext): Promise; +} diff --git a/plugins/huixin615/agent-flow/miniapp/node/server.mjs b/plugins/huixin615/agent-flow/miniapp/node/server.mjs new file mode 100644 index 00000000..6a5d6895 --- /dev/null +++ b/plugins/huixin615/agent-flow/miniapp/node/server.mjs @@ -0,0 +1,79 @@ +// @ts-check +import { readFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { join } from 'node:path'; +import { DataError, listConversations, readFlow, readTaskDetail } from './data.mjs'; + +/** @typedef {import('./miniapp-api.js').MiniAppContext} MiniAppContext */ + +function send(response, status, body, type = 'application/json; charset=utf-8') { + response.writeHead(status, { + 'content-type': type, + 'cache-control': 'no-store', + 'x-content-type-options': 'nosniff', + }); + response.end(body); +} + +/** @param {MiniAppContext} context */ +export async function start(context) { + const client = join(context.pluginRoot, 'miniapp', 'client'); + const [html, css, js] = await Promise.all([ + readFile(join(client, 'index.html')), + readFile(join(client, 'app.css')), + readFile(join(client, 'app.js')), + ]); + const server = createServer((request, response) => { + void (async () => { + const url = new URL(request.url ?? '/', 'http://miniapp.local'); + if (request.method !== 'GET') return send(response, 405, JSON.stringify({ error: 'method_not_allowed' })); + if (url.pathname === '/dashboard') return send(response, 200, html, 'text/html; charset=utf-8'); + if (url.pathname === '/app.css') return send(response, 200, css, 'text/css; charset=utf-8'); + if (url.pathname === '/app.js') return send(response, 200, js, 'text/javascript; charset=utf-8'); + try { + if (url.pathname === '/api/sessions') { + return send(response, 200, JSON.stringify({ + ...await listConversations(undefined, Number(url.searchParams.get('limit') || 40), url.searchParams.get('pin')), + selection: 'inferred-most-recent', + })); + } + if (url.pathname === '/api/flow') { + return send(response, 200, JSON.stringify(await readFlow(url.searchParams.get('id') ?? ''))); + } + if (url.pathname === '/api/task') { + return send(response, 200, JSON.stringify(await readTaskDetail( + url.searchParams.get('id') ?? '', url.searchParams.get('task') ?? '', + ))); + } + } catch (error) { + const known = error instanceof DataError; + return send(response, known ? 400 : 500, JSON.stringify({ + error: known ? error.code : 'INTERNAL_ERROR', + message: known ? error.message : '读取本机记录时发生错误。', + })); + } + return send(response, 404, JSON.stringify({ error: 'not_found' })); + })(); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(context.listen.port, context.listen.host, () => { + server.off('error', reject); + resolve(); + }); + }); + context.logger.info('agent-flow.runtime.listening'); + + let disposed = false; + const dispose = async () => { + if (disposed) return; + disposed = true; + context.signal.removeEventListener('abort', onAbort); + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + }; + const onAbort = () => { void dispose(); }; + context.signal.addEventListener('abort', onAbort, { once: true }); + if (context.signal.aborted) await dispose(); + return { dispose }; +} diff --git a/plugins/huixin615/agent-flow/package.json b/plugins/huixin615/agent-flow/package.json new file mode 100644 index 00000000..aab6b8dc --- /dev/null +++ b/plugins/huixin615/agent-flow/package.json @@ -0,0 +1,6 @@ +{ + "mcode": { + "schemaVersion": 2, + "miniApp": "./miniapp/miniapp.json" + } +} diff --git a/plugins/huixin615/agent-flow/tests/data.test.mjs b/plugins/huixin615/agent-flow/tests/data.test.mjs new file mode 100644 index 00000000..2b557cdc --- /dev/null +++ b/plugins/huixin615/agent-flow/tests/data.test.mjs @@ -0,0 +1,260 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { DatabaseSync } from 'node:sqlite'; +import { analyzeExecution, extractTaskEvidence, listConversations, readFlow, readTaskDetail } from '../miniapp/node/data.mjs'; + +const parentId = 'mvs_parent'; +const childId = 'mvs_child'; +const parentDir = `2026/10/24/00-00-00-000-session_${Buffer.from(parentId).toString('base64url')}`; +const childDir = `2026/10/24/00-00-01-000-session_${Buffer.from(childId).toString('base64url')}`; + +async function fixture({ badPath = false, childParent = parentId, newerIdle = false } = {}) { + const home = await mkdtemp(join(tmpdir(), 'agent-flow-')); + await mkdir(join(home, 'v2', 'sqlite'), { recursive: true }); + const db = new DatabaseSync(join(home, 'v2', 'sqlite', 'runtime-state.sqlite')); + db.exec(`CREATE TABLE local_runtime_sessions ( + session_id TEXT, parent_session_id TEXT, session_kind TEXT, title TEXT, + status TEXT, created_at_ms INTEGER, updated_at_ms INTEGER, history_relative_dir TEXT); + CREATE TABLE local_runtime_background_tasks ( + kind TEXT, owner_session_id TEXT, created_at_ms INTEGER, record_json TEXT);`); + const insertSession = db.prepare('INSERT INTO local_runtime_sessions VALUES (?,?,?,?,?,?,?,?)'); + insertSession.run(parentId, null, 'conversation', 'Demo parent', 'idle', 100, 200, badPath ? '../outside' : parentDir); + insertSession.run(childId, childParent, 'task', 'Demo child', 'idle', 110, 190, childDir); + if (newerIdle) insertSession.run('mvs_newer', null, 'conversation', 'Newer empty chat', 'idle', 300, 400, parentDir); + const task = { + taskId: 'bg_1', kind: 'subagent', status: 'succeeded', ownerSessionId: parentId, + description: 'Read a file', toolCallId: 'call_1', createdAt: 110, startedAt: 120, + endedAt: 190, deliveredAt: 191, + metadata: { parentSessionId: parentId, parentTurnId: 'turn_1', childSessionId: childId, + agentName: 'explore', executionMode: 'foreground' }, + }; + db.prepare('INSERT INTO local_runtime_background_tasks VALUES (?,?,?,?)').run('subagent', parentId, 110, JSON.stringify(task)); + db.close(); + const writeHistory = async (rel, rows) => { + const dir = join(home, 'v2', 'sessions', rel); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, 'messages.jsonl'), rows.map((row) => JSON.stringify(row)).join('\n') + '\n'); + }; + if (!badPath) await writeHistory(parentDir, [ + { message: { role: 'assistant', content: [{ type: 'toolCall', name: 'task', id: 'call_1', arguments: { prompt: 'Read README only' } }] } }, + { message: { role: 'toolResult', toolName: 'task', toolCallId: 'call_1', content: [{ type: 'text', text: 'Done: README describes the project.' }] } }, + ]); + await writeHistory(childDir, [ + { message: { role: 'user', content: [{ type: 'text', text: 'host text' }] } }, + { message: { role: 'assistant', content: [{ type: 'toolCall', name: 'read', id: 'read_1', arguments: {} }] } }, + { message: { role: 'assistant', content: [{ type: 'text', text: 'README describes the project.' }] } }, + ]); + return { home, cleanup: () => rm(home, { recursive: true, force: true }) }; +} + +test('links task, child session, parent tool result and child final reply', async () => { + const f = await fixture(); + try { + const { sessions } = await listConversations(f.home); + assert.deepEqual(sessions.map((session) => session.id), [parentId]); + const flow = await readFlow(parentId, f.home); + assert.equal(flow.tasks.length, 1); + const task = flow.tasks[0]; + assert.equal(task.childId, childId); + assert.equal(task.parentResult, true); + assert.equal(task.parentState, 'matched'); + assert.equal(task.instruction, 'Read README only'); + assert.equal(task.finalText, undefined); + const detail = await readTaskDetail(parentId, 'bg_1', f.home); + assert.equal(detail.finalText, 'README describes the project.'); + assert.deepEqual(detail.tools, ['read']); + assert.equal(task.deliveredAt, 191); + } finally { await f.cleanup(); } +}); + +test('older task and conversation remain visible beyond former global caps', async () => { + const f = await fixture(); + try { + const db = new DatabaseSync(join(f.home, 'v2', 'sqlite', 'runtime-state.sqlite')); + const addSession = db.prepare('INSERT INTO local_runtime_sessions VALUES (?,?,?,?,?,?,?,?)'); + const addTask = db.prepare('INSERT INTO local_runtime_background_tasks VALUES (?,?,?,?)'); + db.exec('BEGIN'); + for (let i = 0; i < 1201; i += 1) { + addSession.run(`mvs_idle${i}`, null, 'conversation', 'Idle', 'idle', 1000 + i, 1000 + i, parentDir); + addTask.run('subagent', 'mvs_noise', 1000 + i, JSON.stringify({ + kind: 'subagent', taskId: `bg_noise${i}`, ownerSessionId: 'mvs_noise', + createdAt: 1000 + i, + })); + } + db.exec('COMMIT'); + db.close(); + const firstPage = await listConversations(f.home); + assert.equal(firstPage.sessions[0].id, 'mvs_idle1200'); + assert.equal(firstPage.hasMore, true); + assert.equal((await listConversations(f.home, 1240)).sessions.some(({ id }) => id === parentId), true); + assert.equal((await readFlow(parentId, f.home)).tasks.length, 1); + } finally { await f.cleanup(); } +}); + +test('session picker can request later pages instead of silently hiding task-bearing chats', async () => { + const f = await fixture(); + try { + const db = new DatabaseSync(join(f.home, 'v2', 'sqlite', 'runtime-state.sqlite')); + const addSession = db.prepare('INSERT INTO local_runtime_sessions VALUES (?,?,?,?,?,?,?,?)'); + const addTask = db.prepare('INSERT INTO local_runtime_background_tasks VALUES (?,?,?,?)'); + db.exec('BEGIN'); + for (let i = 0; i < 45; i += 1) { + const id = `mvs_recent${i}`; + addSession.run(id, null, 'conversation', 'Recent', 'idle', 1000 + i, 1000 + i, parentDir); + addTask.run('subagent', id, 1000 + i, JSON.stringify({ + kind: 'subagent', taskId: `bg_recent${i}`, ownerSessionId: id, createdAt: 1000 + i, + })); + } + db.exec('COMMIT'); + db.close(); + const firstPage = await listConversations(f.home); + assert.equal(firstPage.sessions.length, 40); + assert.equal(firstPage.hasMore, true); + assert.equal(firstPage.sessions.some(({ id }) => id === parentId), false); + const pinned = await listConversations(f.home, 40, parentId); + assert.equal(pinned.sessions.at(-1).id, parentId); + const expanded = await listConversations(f.home, 80); + assert.equal(expanded.sessions.some(({ id }) => id === parentId), true); + assert.equal(expanded.hasMore, false); + } finally { await f.cleanup(); } +}); + +test('rejects a valid-looking history path belonging to another session', async () => { + const f = await fixture(); + try { + const db = new DatabaseSync(join(f.home, 'v2', 'sqlite', 'runtime-state.sqlite')); + db.prepare('UPDATE local_runtime_sessions SET history_relative_dir = ? WHERE session_id = ?').run(parentDir, childId); + db.close(); + const detail = await readTaskDetail(parentId, 'bg_1', f.home); + assert.equal(detail.finalText, ''); + assert.match(detail.historyWarning, /会话 ID 不一致/); + } finally { await f.cleanup(); } +}); + +test('rejects a history symlink even when its target stays under the sessions root', async () => { + const f = await fixture(); + try { + const childFile = join(f.home, 'v2', 'sessions', childDir, 'messages.jsonl'); + await rm(childFile); + await symlink(join(f.home, 'v2', 'sessions', parentDir, 'messages.jsonl'), childFile); + const detail = await readTaskDetail(parentId, 'bg_1', f.home); + assert.equal(detail.finalText, ''); + assert.match(detail.historyWarning, /符号链接/); + } finally { await f.cleanup(); } +}); + +test('marks a delivered result unverified when active parent history no longer contains it', async () => { + const f = await fixture(); + try { + await writeFile(join(f.home, 'v2', 'sessions', parentDir, 'messages.jsonl'), + JSON.stringify({ message: { role: 'assistant', content: [{ type: 'text', text: 'Later summary' }] } }) + '\n'); + const task = (await readFlow(parentId, f.home)).tasks[0]; + assert.equal(task.parentResult, false); + assert.equal(task.parentState, 'unverified'); + assert.equal(task.deliveredAt, 191); + } finally { await f.cleanup(); } +}); + +test('links a nested failed background task to its parent task', async () => { + const f = await fixture(); + try { + const grandchildId = 'mvs_grandchild'; + const grandchildDir = `2026/10/24/00-00-02-000-session_${Buffer.from(grandchildId).toString('base64url')}`; + const db = new DatabaseSync(join(f.home, 'v2', 'sqlite', 'runtime-state.sqlite')); + db.prepare('INSERT INTO local_runtime_sessions VALUES (?,?,?,?,?,?,?,?)') + .run(grandchildId, childId, 'task', 'Nested child', 'idle', 125, 185, grandchildDir); + db.prepare('INSERT INTO local_runtime_background_tasks VALUES (?,?,?,?)') + .run('subagent', childId, 125, JSON.stringify({ + kind: 'subagent', taskId: 'bg_nested', ownerSessionId: childId, + status: 'failed', description: 'Read nested file', toolCallId: 'call_nested', + createdAt: 125, startedAt: 130, endedAt: 180, deliveredAt: 181, + metadata: { parentSessionId: childId, parentTurnId: 'turn_nested', + childSessionId: grandchildId, executionMode: 'background', agentName: 'explore' }, + })); + db.close(); + await writeFile(join(f.home, 'v2', 'sessions', childDir, 'messages.jsonl'), [ + { message: { role: 'assistant', content: [{ type: 'toolCall', name: 'task', id: 'call_nested', arguments: { prompt: 'Inspect nested file' } }] } }, + { message: { role: 'toolResult', toolName: 'task', toolCallId: 'call_nested', content: [{ type: 'text', text: 'Task failed' }] } }, + { message: { role: 'assistant', content: [{ type: 'text', text: 'Nested task failed.' }] } }, + ].map((row) => JSON.stringify(row)).join('\n') + '\n'); + await mkdir(join(f.home, 'v2', 'sessions', grandchildDir), { recursive: true }); + await writeFile(join(f.home, 'v2', 'sessions', grandchildDir, 'messages.jsonl'), + JSON.stringify({ message: { role: 'assistant', content: [{ type: 'text', text: 'Could not read file.' }] } }) + '\n'); + const flow = await readFlow(parentId, f.home); + const nested = flow.tasks.find(({ id }) => id === 'bg_nested'); + assert.equal(flow.tasks.length, 2); + assert.equal(nested.depth, 1); + assert.equal(nested.parentTaskId, 'bg_1'); + assert.equal(nested.mode, 'background'); + assert.equal(nested.status, 'failed'); + assert.equal(nested.parentState, 'matched'); + assert.equal((await readTaskDetail(parentId, nested.id, f.home)).finalText, 'Could not read file.'); + } finally { await f.cleanup(); } +}); + +test('defaults to the newest conversation even before it has sub-agent tasks', async () => { + const f = await fixture({ newerIdle: true }); + try { + const { sessions } = await listConversations(f.home); + assert.deepEqual(sessions.map(({ id, taskCount }) => [id, taskCount]), + [['mvs_newer', 0], [parentId, 1]]); + assert.deepEqual((await readFlow('mvs_newer', f.home)).tasks, []); + } finally { await f.cleanup(); } +}); + +test('rejects a child whose parent link contradicts the task record', async () => { + const f = await fixture({ childParent: 'mvs_other' }); + try { + const [selected] = (await readFlow(parentId, f.home)).tasks; + assert.equal(selected.childId, null); + assert.match(selected.warning, /不一致/); + } finally { await f.cleanup(); } +}); + +test('does not attribute one child session to two different task records', async () => { + const f = await fixture(); + try { + const db = new DatabaseSync(join(f.home, 'v2', 'sqlite', 'runtime-state.sqlite')); + db.prepare('INSERT INTO local_runtime_background_tasks VALUES (?,?,?,?)').run('subagent', parentId, 115, + JSON.stringify({ kind: 'subagent', taskId: 'bg_second', ownerSessionId: parentId, + createdAt: 115, metadata: { childSessionId: childId, parentSessionId: parentId } })); + db.close(); + const tasks = (await readFlow(parentId, f.home)).tasks; + assert.equal(tasks[0].childId, childId); + assert.equal(tasks[1].childId, null); + assert.match(tasks[1].warning, /多项任务/); + } finally { await f.cleanup(); } +}); + +test('does not open a history path outside the session directory', async () => { + const f = await fixture({ badPath: true }); + try { + const flow = await readFlow(parentId, f.home); + assert.match(flow.tasks[0].historyWarning, /路径格式/); + assert.equal(flow.tasks[0].parentState, 'unreadable'); + assert.equal(flow.tasks[0].instruction, ''); + } finally { await f.cleanup(); } +}); + +test('does not label injected child text as an instruction or final reply', () => { + const evidence = extractTaskEvidence([], [{ message: { role: 'user', content: [ + { type: 'text', text: 'host text' }, + ] } }], 'call_1'); + assert.equal(evidence.instruction, ''); + assert.equal(evidence.finalText, ''); +}); + +test('uses observed task intervals to distinguish serial and overlapping execution', () => { + const base = { ownerId: parentId, parentTurnId: 'turn_1', deliveredAt: 201, parentResult: true }; + const first = { ...base, startedAt: 100, endedAt: 120 }; + const second = { ...base, startedAt: 120, endedAt: 170 }; + assert.deepEqual(analyzeExecution([first, second]), { + sameTurnGroups: [{ count: 2, execution: 'serial', wallMs: 70 }], + parentResults: 2, delivered: 2, + }); + assert.equal(analyzeExecution([first, { ...second, startedAt: 110 }]).sameTurnGroups[0].execution, 'overlap'); + assert.equal(analyzeExecution([first, { ...second, endedAt: null }]).sameTurnGroups[0].execution, 'incomplete'); +}); From 3ce9bee363d26d3e99ffcab900a04775b53ef720 Mon Sep 17 00:00:00 2001 From: mahuixin Date: Sun, 25 Oct 2026 13:26:58 +0800 Subject: [PATCH 2/2] feat(agent-flow): observe subagents through plugin hooks --- README.md | 2 +- README.zh-CN.md | 2 +- .../agent-flow/.minimax-plugin/plugin.json | 7 +- plugins/huixin615/agent-flow/README.md | 28 +- plugins/huixin615/agent-flow/README.zh-CN.md | 28 +- plugins/huixin615/agent-flow/hooks/hooks.json | 9 + .../huixin615/agent-flow/hooks/normalize.mjs | 43 ++ plugins/huixin615/agent-flow/hooks/record.mjs | 53 ++ .../agent-flow/miniapp/client/app.css | 131 +++-- .../agent-flow/miniapp/client/app.js | 464 ++++++++---------- .../agent-flow/miniapp/client/flow.mjs | 133 +++++ .../agent-flow/miniapp/client/index.html | 59 ++- .../agent-flow/miniapp/node/data.mjs | 366 -------------- .../agent-flow/miniapp/node/server.mjs | 59 +-- plugins/huixin615/agent-flow/package.json | 1 + .../agent-flow/tests/client.test.mjs | 92 ++++ .../huixin615/agent-flow/tests/data.test.mjs | 260 ---------- .../agent-flow/tests/events.test.mjs | 159 ++++++ 18 files changed, 900 insertions(+), 996 deletions(-) create mode 100644 plugins/huixin615/agent-flow/hooks/hooks.json create mode 100644 plugins/huixin615/agent-flow/hooks/normalize.mjs create mode 100644 plugins/huixin615/agent-flow/hooks/record.mjs create mode 100644 plugins/huixin615/agent-flow/miniapp/client/flow.mjs delete mode 100644 plugins/huixin615/agent-flow/miniapp/node/data.mjs create mode 100644 plugins/huixin615/agent-flow/tests/client.test.mjs delete mode 100644 plugins/huixin615/agent-flow/tests/data.test.mjs create mode 100644 plugins/huixin615/agent-flow/tests/events.test.mjs diff --git a/README.md b/README.md index d76b6750..40970658 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ The MiniMax Code Agent communicates through the host MCP client. Business reques | [Disk Insight](plugins/zch0227/disk-insight/) | See where disk space went on macOS and Windows with a sunburst space map, find junk files sorted into three safety tiers, large and duplicate files, and how much code repositories and Git worktrees take; ask an Agent about any item, and permanently delete what you select after a confirmation that lists every item. Requires MiniMax Code 3.1.2 or later | [zch0227](https://github.com/zch0227) | | [A/时差保安 / Timezone Guard](plugins/liuchen19960902/timezone-guard/) | Check the other side's local time before you message: the target city's clock turns into a green "now is fine", yellow "keep it short", or red "go to sleep" verdict. Requires MiniMax Code 3.1.2 or later | [FineCat](https://github.com/LiuChen19960902) | | [AI Node Canvas](plugins/antianqi/ai-node-canvas/) | An infinite-canvas node graph for MiniMax Code: wire text, image, video and audio runs into a graph, let each result flow downstream, and run media tools on any result | [antianqi](https://github.com/antianqi) | -| [Agent Flow](plugins/huixin615/agent-flow/) | Inspect native sub-agent timelines across parent and child sessions: actual serial or overlapping execution, child tool activity, and result matches in the current parent history. Requires MiniMax Code 3.1.4 or later | [Huixin615](https://github.com/Huixin615) | +| [Agent Flow](plugins/huixin615/agent-flow/) | Import this plugin's Hook event file to inspect native task calls and sub-agent lifecycles, with exact task-to-child links when IDs are available. Choose a recorded chat or the latest imported record; no Host session files are opened. Requires MiniMax Code 3.1.4 or later | [Huixin615](https://github.com/Huixin615) |
Preview: Token Usage Board diff --git a/README.zh-CN.md b/README.zh-CN.md index 96eedccf..57467b2f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -78,7 +78,7 @@ MiniMax Code Agent 通过宿主 MCP 客户端与 MiniApp 协作。业务请求 | [磁盘透视](plugins/zch0227/disk-insight/README.zh-CN.md) | 查看 macOS 和 Windows 的磁盘空间去向:旭日图空间地图,按三档风险分类的垃圾文件,大文件和重复文件,代码仓库和 Git worktree 的占用;任何条目都可以问 Agent,选中的条目在确认框里逐一列出,确认后直接永久删除。需要 MiniMax Code 3.1.2 或更高版本 | [zch0227](https://github.com/zch0227) | | [A/时差保安](plugins/liuchen19960902/timezone-guard/README.zh-CN.md) | 发消息前先看对方那边几点:按对方城市当地时间给出「现在可以聊 / 短聊就好 / 先睡,别发」三档判定。需要 MiniMax Code 3.1.2 或更高版本 | [FineCat](https://github.com/LiuChen19960902) | | [AI 节点画布](plugins/antianqi/ai-node-canvas/README.zh-CN.md) | MiniMax Code 里的无限画布节点图:把文本、图片、视频、音频的生成接成一张图,结果顺着连线往下传,任何一张结果都能直接做二次处理 | [antianqi](https://github.com/antianqi) | -| [Agent 协作透视](plugins/huixin615/agent-flow/README.zh-CN.md) | 跨父子会话查看原生子 Agent 时间线:实际串行或重叠执行、子任务工具活动,以及当前父会话历史中的回传匹配情况;本机只读。需要 MiniMax Code 3.1.4 或更高版本 | [Huixin615](https://github.com/Huixin615) | +| [Agent 协作透视](plugins/huixin615/agent-flow/README.zh-CN.md) | 手动导入插件 Hook 记录,查看原生 task 调用与子 Agent 运行区间;有明确 ID 时关联父子任务。可选择记录会话或一键查看最近导入的记录,不读取宿主会话文件。需要 MiniMax Code 3.1.4 或更高版本 | [Huixin615](https://github.com/Huixin615) |
预览:Token 用量看板 diff --git a/plugins/huixin615/agent-flow/.minimax-plugin/plugin.json b/plugins/huixin615/agent-flow/.minimax-plugin/plugin.json index 126b3146..2189a0b2 100644 --- a/plugins/huixin615/agent-flow/.minimax-plugin/plugin.json +++ b/plugins/huixin615/agent-flow/.minimax-plugin/plugin.json @@ -3,12 +3,13 @@ "name": "agent-flow", "displayName": "Agent 协作透视", "version": "1.0.0", - "description": "Read-only timeline of native sub-agent tasks, child sessions, and result handoffs in local MiniMax Code conversations.", + "description": "Import plugin-recorded Hook events to inspect native sub-agent and task timing without reading Host session files.", "author": "Huixin615", "icon": "icon.png", "category": "Code", - "exampleQueries": ["打开 Agent 协作透视", "查看这次任务的子 Agent 协作过程"], + "exampleQueries": ["打开 Agent 协作透视", "查看插件记录的 task 与子 Agent 时间事件"], "apps": [], "mcpServers": [], - "skills": [] + "skills": [], + "hooks": ["hooks/hooks.json"] } diff --git a/plugins/huixin615/agent-flow/README.md b/plugins/huixin615/agent-flow/README.md index 1604f374..a48ae4ba 100644 --- a/plugins/huixin615/agent-flow/README.md +++ b/plugins/huixin615/agent-flow/README.md @@ -1,35 +1,35 @@ # Agent Flow (`agent-flow`) -A read-only Mini App for inspecting **native `task` sub-agents** in local MiniMax Code conversations. Open it while working normally: it shows when a parent Agent delegated work, the child session and its tool activity, the child's final reply, and whether a matching result appeared in the parent conversation. It does not launch or control Agents. +Agent Flow shows a timeline of native `task` calls and sub-agent lifecycle events from a file that **this plugin's Hooks create**. The user works in ordinary MiniMax Code chats. The Mini App reads a file only after the user selects it; it does not discover or open Host runtime databases or session history. -The timeline uses persisted task times and reports whether tasks delegated in one turn actually overlapped, plus whether each result can be matched in the current parent history. Two tool calls in one parent message are not assumed to have run in parallel. A task marked “delivered” is not treated as a verified parent-history match; compaction or an unreadable history can leave that match unverified. A match does not prove the parent understood or used the result. +The timeline shows observed `PreToolUse` → `PostToolUse` intervals, whether a tool result field was present, and `SubagentStart` → `SubagentStop` intervals. It compares completed task-call intervals for overlap. A tool result does not prove that the parent Agent adopted it. A child is linked only when the structured `task` result names the same child session ID as its lifecycle events, or a Hook explicitly supplies a parent session ID. The selected-session view excludes unrelated and unlinked child events; the import file itself is unchanged. ## Install and use -Requires MiniMax Code 3.1.4 or later. +Requires MiniMax Code 3.1.4 or later and `node` available on the Hook command's `PATH`. -Copy this whole directory, including `.minimax-plugin/`, to `~/.minimax/plugins/agent-flow/`. Restart MiniMax Code, then ask it to “Open Agent Flow” or “打开 Agent 协作透视”. The Mini App tab belongs to the conversation where it was opened; the page can still inspect earlier conversations through its picker. It follows the most recently updated local conversation as an **inference**, including conversations with zero tasks, because the Host does not expose the current chat ID to this Mini App. Use the picker to hold a specific conversation, or choose “Follow latest” to resume automatic selection. It refreshes task data every three seconds and the conversation list every fifteen seconds while open; it stops when closed. +Copy this directory, including `.minimax-plugin/` and `hooks/`, to the local MiniMax Code plugin directory as `agent-flow`, then restart MiniMax Code. Work normally and invoke native `task` sub-agents. Open “Agent 协作透视” and select `agent-flow-events.jsonl` from this plugin's Hook data directory. Select rotated `agent-flow-events-*.jsonl` files together if needed. -The detail panel shows the `task` instruction, the child Agent's final reply, and tool names. These are recorded statements and actions, not proof that an advertised code change is correct. Previous messages removed from the active history by compaction may no longer be available in the detail panel; the persisted task and parent-child relation remain visible. The session picker starts with 40 entries and offers “Load more” when needed. +On the tested macOS desktop layout, Finder's **Go to Folder** can navigate to `~/.minimax/v2/plugin-data/hooks/agent-flow/`. This is a **manual file-picker hint**, not a path used by the plugin's code or a promised stable Host layout. If the directory is empty, check that the plugin is enabled, a new native task has run since installation, and `node` is available to the desktop application's Hook process. -This version covers native `task` sub-agents only. MiniMax Bot groups, MoMo-managed tasks, cloud sessions, and arbitrary plugin-owned Agent sessions are outside its scope. +Choose the file again to see newly appended events. The page keeps imported data only in memory and does not poll the file. Records from before the Hooks were installed cannot be reconstructed. Session identifiers are SHA-256 digests; the picker lists sessions with observed `task` calls, sorted by their latest event with full local dates and times. Choose a session or click “查看最近记录” to select the newest **imported** task session in one step. This selection is an inference and may not be the chat containing the Mini App; the page never binds it automatically to the current chat. Only children explicitly associated with the chosen session appear in its view. ## Tested environment -- MiniMax Code 3.1.4 on macOS: read-only reconstruction verified against one real parent conversation with two foreground `explore` children and matching parent tool results. The plugin appears in the installed plugin list, and the user confirmed that the Mini App page displays both tasks. -- Node.js 24.21.0: synthetic tests cover old sessions, mismatched paths, missing parent results, and nested failed background tasks; the repository `npm run check` passes. -- Windows paths are implemented with Node's cross-platform path utilities but have not been tested on Windows. +- MiniMax Code 3.1.4 on macOS: after installation and restart, a new Work chat produced two `task` calls, two tool returns, and two complete sub-agent lifecycles. The user imported the plugin-generated file and confirmed that both calls matched their respective children by exact hashed session ID. The two child running intervals overlapped. Older records created before structured child ID capture remain unlinked. +- Node.js 24.21.0 for the Hook recorder and synthetic tests. Windows has not been tested. ## Data & access -- Reads `~/.minimax/v2/sqlite/runtime-state.sqlite` with `node:sqlite` in read-only mode, plus selected session `messages.jsonl` files under `~/.minimax/v2/sessions`. It checks that a history directory encodes the selected session ID, rejects symlinked history paths, and caches unchanged files while open. If `MAVIS_HOME` is set, that directory replaces `~/.minimax`. -- The Mini App Host does not provide a supported API to observe other conversations. These paths and schemas are **internal, undocumented layouts** observed in MiniMax Code 3.1.4; a client update may break them. A missing or incompatible store produces an error, not guessed relationships. -- Does not write Host files, plugin data, or browser storage. No outbound network requests, spawned processes, Agent turns, MCP endpoints, or background monitoring after the page closes. -- The browser receives only selected conversation and task details from the local Node process. Nothing is uploaded. The page renders stored text as text, never as HTML. +- The `SessionStart`, `PreToolUse(task)`, `PostToolUse(task)`, `SubagentStart`, and `SubagentStop` Hooks receive event JSON from MiniMax Code on stdin. Each Hook invocation runs `node "${PLUGIN_ROOT}/hooks/record.mjs"`. These invocations can occur while the Mini App page is closed. +- The recorder writes `agent-flow-events.jsonl` under the runtime-provided `PLUGIN_DATA` directory, rotating at 8 MiB. It keeps timestamps, SHA-256 digests of opaque IDs (including a structured child session ID when present in a `task` result), agent type, a task description/title/name truncated to 160 characters, and whether the tool response field exists. **That short task title may contain user-supplied text, including a path or sensitive value.** It does not separately collect workspace paths or credentials and does not store the full task prompt, tool result body, or child final reply. The records remain on the user's machine until the user deletes them. +- The Mini App's Node process serves only its own HTML, CSS, and JavaScript. The browser parses only files explicitly selected by the user, in memory, and does not upload or persist the content. No Mini App Agent turns, MCP endpoints, outbound network requests, or Host runtime/session-file access. Hook processes are the only subprocesses; no additional process is started by the Mini App. +- The Mini App declares no Agent access. It cannot identify the chat containing the page and does not list or read Host sessions or run Agent turns. The user chooses a recorded session explicitly; choosing one does not claim it is the currently open chat. +- The UI renders imported strings as text. A mismatched, incomplete, or oversized file is rejected rather than merged into a guessed history. ## Development -Run `node --test plugins/huixin615/agent-flow/tests/*.test.mjs` and `npm run check` from the repository root. Synthetic tests create temporary stores and contain no real conversation content. +Run `node --test plugins/huixin615/agent-flow/tests/*.test.mjs` and `npm run check` at the repository root. Tests use only synthetic events and a temporary plugin data directory. ## License diff --git a/plugins/huixin615/agent-flow/README.zh-CN.md b/plugins/huixin615/agent-flow/README.zh-CN.md index 59902090..87751f3e 100644 --- a/plugins/huixin615/agent-flow/README.zh-CN.md +++ b/plugins/huixin615/agent-flow/README.zh-CN.md @@ -1,35 +1,35 @@ # Agent 协作透视(`agent-flow`) -一个只读 MiniApp:用户照常在 MiniMax Code 对话,插件展示原生 `task` 子 Agent 何时启动、执行了哪些工具、最终回复了什么,以及结果是否出现在父会话。插件不创建或控制 Agent。 +插件通过正式 Hook 记录普通 MiniMax Code 会话中原生 `task` 调用和子 Agent 启停。用户在 MiniApp 内**手动选择插件生成的记录文件**,查看调用时间线。插件代码不打开宿主运行数据库或会话历史。 -时间线使用任务实际起止时间,并对同一轮委派的任务标出运行区间是否重叠及结果在当前父会话历史中的匹配情况。父 Agent 同一条消息中提出两个任务,不代表两者并行。任务记录中的“已交付”不等于父会话历史已核实回传;历史被压缩或无法读取时会标为暂无法核实。即使匹配成功,也不表示父 Agent 已理解、采用或验证结果。 +页面展示 `PreToolUse` 至 `PostToolUse` 的调用区间、工具回传字段是否存在,以及 `SubagentStart` 至 `SubagentStop` 的区间;已结束的 task 调用可以比较是否重叠。工具回传不表示父 Agent 已采纳结果。只有结构化 task 回传中的子会话 ID 与子 Agent 启停事件的会话 ID 一致,或 Hook 明确给出父会话 ID 时才建立关联。所选会话视图不展示其他会话及未关联的子 Agent;导入文件本身不会被修改。 ## 安装与使用 -需要 MiniMax Code 3.1.4 或更高版本。 +需要 MiniMax Code 3.1.4 或更高版本,并且 Hook 进程的 `PATH` 中能够运行 `node`。 -将整个目录(含 `.minimax-plugin/`)复制到 `~/.minimax/plugins/agent-flow/`,重启 MiniMax Code,然后说“打开 Agent 协作透视”。MiniApp 标签页附属于打开它的会话;在别的会话打开后,仍可在页面内选择之前的会话。宿主没有向插件提供当前聊天的 ID,因此页面**推断**最近更新的本机会话并自动跟随,包括尚无子任务的新会话;手动选择会话后保持选择,点击“跟随最新会话”可恢复自动跟随。页面打开时每三秒刷新任务、每十五秒刷新会话列表,关闭后不运行后台监控。 +将整个插件目录(含 `.minimax-plugin/` 与 `hooks/`)安装为本地 `agent-flow` 插件,重启 MiniMax Code。照常在 Work 中使用原生 `task` 子 Agent;打开“Agent 协作透视”,选择插件 Hook 数据目录中的 `agent-flow-events.jsonl`。如有 `agent-flow-events-*.jsonl` 轮转文件,可以一起选择。 -点击子任务可查看父 Agent 发出的 `task` 指令、子 Agent 最终回复与工具名称。这些内容是记录中的陈述和操作,不能单独证明代码修改正确。若旧消息已被压缩移出活动历史,详情可能缺失,但持久化的任务与父子关系仍可见。会话列表先显示 40 条,需要时可点“加载更多会话”。 +在已测试的 macOS 桌面版目录布局中,可在 Finder 使用“前往文件夹”打开 `~/.minimax/v2/plugin-data/hooks/agent-flow/`。这只是供人手动选文件的提示;插件代码不会依赖这个宿主路径,它也不是稳定格式承诺。若文件不存在,先确认插件已启用、安装后运行过新的原生 task,且桌面版 Hook 进程能找到 `node`。 -首版只覆盖原生 `task` 子 Agent,不覆盖 MiniMax Bot 群聊、MoMo 自管任务、云端会话或任意插件自建 Agent 会话。 +文件新增事件后需要重新选择。导入内容只存在页面内存中,不自动轮询。Hook 安装前的历史无法恢复。会话 ID 只显示 SHA-256 散列值;选择器列出观察到 `task` 调用的会话,按带日期的最近事件排序。可手动选择,或点“查看最近记录”一键查看导入文件中最近的 task 会话。这个选择只是推断,可能不是打开插件的聊天;插件不会自动把它绑定为当前聊天。视图只展示与所选会话有明确关联的子 Agent。 ## Tested environment -- MiniMax Code 3.1.4 / macOS:已用一条含两个前台 `explore` 子 Agent 的真实本机会话验证任务、子会话、父会话结果可以关联;插件已显示在“插件·技能”列表,用户在桌面端打开页面并确认显示两项子任务。 -- Node.js 24.21.0:合成测试覆盖旧会话、错误路径、父会话回传缺失以及嵌套的失败后台任务;已运行仓库检查。 -- 使用 Node 跨平台路径 API;Windows 尚未实测。 +- MiniMax Code 3.1.4 / macOS:安装并重启后,新 Work 会话产生了两次 `task` 调用、两次工具回传和两个完整的子 Agent 生命周期。用户导入插件记录,确认两次调用都通过精确的子会话 ID 散列匹配到对应子 Agent,且两个运行区间重叠。启用结构化子会话 ID 提取之前的旧记录仍未关联。 +- Node.js 24.21.0:使用合成事件和临时插件数据目录运行测试。Windows 尚未实测。 ## Data & access -- 只读打开 `~/.minimax/v2/sqlite/runtime-state.sqlite`,并读取 `~/.minimax/v2/sessions` 中所选会话的 `messages.jsonl`;读取前核对历史目录编码的会话 ID,拒绝符号链接路径,运行期间缓存未变化的历史文件。设置 `MAVIS_HOME` 时以该目录为根。 -- MiniApp 宿主没有提供观察其他普通会话的稳定 API。以上路径和结构是 MiniMax Code 3.1.4 中观察到的**未承诺稳定的内部格式**;版本变化可能导致读取失败。关联不一致时显示错误或警告,不猜测关系。 -- 不写入宿主文件、插件数据或浏览器存储;不向外联网、不启动进程或 Agent、不提供 MCP,也不在页面关闭后继续监控。 -- 文本只送到本机 MiniApp 页面,并按纯文本渲染。 +- `SessionStart`、`PreToolUse(task)`、`PostToolUse(task)`、`SubagentStart`、`SubagentStop` Hook 从标准输入读取宿主提供的事件 JSON。每次调用运行 `node "${PLUGIN_ROOT}/hooks/record.mjs"`;页面关闭后 Hook 仍可记录事件。 +- 记录写入运行时提供的 `PLUGIN_DATA` 目录中的 `agent-flow-events.jsonl`,达到 8 MiB 后轮转。仅保存时间、关联 ID 的 SHA-256 散列(包括 task 结构化结果中可能提供的子会话 ID)、Agent 类型、最多 160 字符的任务描述/标题/名称,以及工具结果字段是否存在。**短任务标题可能包含用户输入,包括路径或敏感值。** 插件不单独收集工作区路径或凭据字段,也不保存完整指令、工具结果正文或子 Agent 最终回复。记录留在用户本机,直至用户自行删除。 +- MiniApp Node 进程只提供自身页面资源。浏览器仅解析用户显式选择的文件,导入内容只留在内存;不上传、不持久化。不发起 MiniApp Agent 回合,不提供 MCP,不向外联网,也不读取宿主数据库或会话文件。只有 Hook 调用会启动 `node` 子进程。 +- MiniApp 不声明 Agent 访问权限,无法识别打开页面的聊天,不列出或读取宿主会话,也不启动 Agent 回合。用户明确选择记录中的会话;选中不代表它就是当前聊天。 +- 页面将导入文字作为纯文本显示。格式错误、不完整或过大的文件会报错,不拼接推测出的历史。 ## 开发 -在仓库根目录运行 `node --test plugins/huixin615/agent-flow/tests/*.test.mjs` 和 `npm run check`。合成测试不包含真实会话内容。 +在仓库根目录运行 `node --test plugins/huixin615/agent-flow/tests/*.test.mjs` 与 `npm run check`。测试不包含真实会话内容。 ## License diff --git a/plugins/huixin615/agent-flow/hooks/hooks.json b/plugins/huixin615/agent-flow/hooks/hooks.json new file mode 100644 index 00000000..751ea67c --- /dev/null +++ b/plugins/huixin615/agent-flow/hooks/hooks.json @@ -0,0 +1,9 @@ +{ + "hooks": { + "SessionStart": [{ "hooks": [{ "type": "command", "command": "node \"${PLUGIN_ROOT}/hooks/record.mjs\"", "timeout": 5 }] }], + "PreToolUse": [{ "matcher": "task", "hooks": [{ "type": "command", "command": "node \"${PLUGIN_ROOT}/hooks/record.mjs\"", "timeout": 5 }] }], + "PostToolUse": [{ "matcher": "task", "hooks": [{ "type": "command", "command": "node \"${PLUGIN_ROOT}/hooks/record.mjs\"", "timeout": 5 }] }], + "SubagentStart": [{ "hooks": [{ "type": "command", "command": "node \"${PLUGIN_ROOT}/hooks/record.mjs\"", "timeout": 5 }] }], + "SubagentStop": [{ "hooks": [{ "type": "command", "command": "node \"${PLUGIN_ROOT}/hooks/record.mjs\"", "timeout": 5 }] }] + } +} diff --git a/plugins/huixin615/agent-flow/hooks/normalize.mjs b/plugins/huixin615/agent-flow/hooks/normalize.mjs new file mode 100644 index 00000000..b5159ac8 --- /dev/null +++ b/plugins/huixin615/agent-flow/hooks/normalize.mjs @@ -0,0 +1,43 @@ +import { createHash, randomUUID } from 'node:crypto'; + +const EVENT_NAMES = new Set(['SessionStart', 'PreToolUse', 'PostToolUse', 'SubagentStart', 'SubagentStop']); +const digest = (value) => typeof value === 'string' && value + ? createHash('sha256').update(value).digest('hex') : null; + +function childSessionFromResult(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const details = value.details && typeof value.details === 'object' && !Array.isArray(value.details) + ? value.details : {}; + // Native task results expose sub_session_id in details. The compatible Hook + // response exposes the same child session as agentId. Never inspect text. + const candidates = [details.sub_session_id, value.sub_session_id, value.agentId] + .filter((item) => typeof item === 'string' && item.length > 0); + return candidates.length && candidates.every((item) => item === candidates[0]) + ? digest(candidates[0]) : null; +} + +function shortText(value) { + return typeof value === 'string' + ? value.replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, 160) : null; +} + +export function normalizeHookEvent(input, at = Date.now(), id = randomUUID()) { + if (!input || typeof input !== 'object' || !EVENT_NAMES.has(input.hook_event_name)) return null; + const event = input.hook_event_name; + if ((event === 'PreToolUse' || event === 'PostToolUse') && input.tool_name !== 'task') return null; + const session = digest(input.session_id); + if (!session || !Number.isFinite(at)) return null; + const taskInput = input.tool_input && typeof input.tool_input === 'object' ? input.tool_input : {}; + return { + schema: 'agent-flow-hook/v1', id, at, event, session, + parentSession: digest(input.parent_session_id), + turn: digest(input.turn_id), + agent: digest(input.agent_id), + agentType: shortText(input.agent_type), + toolUse: digest(input.tool_use_id), + childSession: event === 'PostToolUse' ? childSessionFromResult(input.tool_response) : null, + taskTitle: event === 'PreToolUse' + ? shortText(taskInput.description ?? taskInput.title ?? taskInput.name) : null, + resultPresent: event === 'PostToolUse' ? input.tool_response != null : null, + }; +} diff --git a/plugins/huixin615/agent-flow/hooks/record.mjs b/plugins/huixin615/agent-flow/hooks/record.mjs new file mode 100644 index 00000000..85b45082 --- /dev/null +++ b/plugins/huixin615/agent-flow/hooks/record.mjs @@ -0,0 +1,53 @@ +import { appendFile, mkdir, open, rename, stat, unlink } from 'node:fs/promises'; +import { join } from 'node:path'; +import { normalizeHookEvent } from './normalize.mjs'; + +const MAX_INPUT = 256 * 1024; +const MAX_FILE = 8 * 1024 * 1024; +const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function appendLocked(dir, line) { + const lockPath = join(dir, 'agent-flow-events.lock'); + let lock; + for (let attempt = 0; attempt < 80; attempt += 1) { + try { + lock = await open(lockPath, 'wx', 0o600); + break; + } catch (error) { + if (error?.code !== 'EEXIST') throw error; + try { + if (Date.now() - (await stat(lockPath)).mtimeMs > 10_000) await unlink(lockPath); + } catch (staleError) { + if (staleError?.code !== 'ENOENT') throw staleError; + } + await pause(25); + } + } + if (!lock) return; + try { + const path = join(dir, 'agent-flow-events.jsonl'); + const size = await stat(path).then((info) => info.size, () => 0); + if (size + Buffer.byteLength(line) > MAX_FILE) { + await rename(path, join(dir, `agent-flow-events-${Date.now()}.jsonl`)); + } + await appendFile(path, line, { mode: 0o600 }); + } finally { + await lock.close(); + await unlink(lockPath).catch(() => {}); + } +} + +try { + let input = ''; + for await (const chunk of process.stdin) { + input += chunk; + if (input.length > MAX_INPUT) throw new Error('hook payload too large'); + } + const row = normalizeHookEvent(JSON.parse(input)); + if (row && process.env.PLUGIN_DATA) { + await mkdir(process.env.PLUGIN_DATA, { recursive: true }); + await appendLocked(process.env.PLUGIN_DATA, `${JSON.stringify(row)}\n`); + } +} catch { + // Observation must never block or change the Agent's tool call. +} diff --git a/plugins/huixin615/agent-flow/miniapp/client/app.css b/plugins/huixin615/agent-flow/miniapp/client/app.css index 777d8787..c8743345 100644 --- a/plugins/huixin615/agent-flow/miniapp/client/app.css +++ b/plugins/huixin615/agent-flow/miniapp/client/app.css @@ -12,7 +12,7 @@ body { min-height:100vh; background:radial-gradient(circle at 85% -10%,#172f4c 0,transparent 34%),#0a1322 } -button,select { +button,select,input { font:inherit } button { @@ -79,12 +79,70 @@ main { margin:auto; padding:28px 30px 36px } +.import-panel { + display:grid; + gap:12px; + border:1px solid #35516e; + background:#10243d; + border-radius:16px; + padding:21px 23px; + margin-bottom:24px +} +.import-panel h1 { + margin:0; + font-size:21px +} +.import-panel p { + margin:10px 0 0; + color:#c7d7e9; + font-size:13px; + line-height:1.6 +} +.import-panel p.hint { + color:#8fa5c0 +} +.import-actions { + display:flex; + align-items:center; + gap:11px; + flex-wrap:wrap +} +.visually-hidden { + position:absolute; + width:1px; + height:1px; + padding:0; + margin:-1px; + overflow:hidden; + clip:rect(0,0,0,0); + white-space:nowrap; + border:0 +} +.file-button { + display:inline-block; + cursor:pointer; + padding:9px 13px; + color:#cde5ff; + background:#19304e; + border:1px solid #3c5b7e; + border-radius:9px +} +.file-button:hover { background:#254466 } +.import-actions input:focus-visible + .file-button { outline:2px solid #8cddbe; outline-offset:2px } +.import-panel code { + color:#b8dbff; + background:#0b1a2d; + padding:1px 4px; + border-radius:4px +} .toolbar { display:flex; justify-content:space-between; align-items:flex-end; gap:20px } +.toolbar { margin-top:6px } +.session-actions { display:flex; align-items:center; gap:10px; flex-wrap:wrap } .eyebrow { font-size:10px; font-weight:800; @@ -125,6 +183,43 @@ select { .toolbar button:hover,.copy:hover { background:#254466 } +.toolbar button:disabled { cursor:not-allowed; opacity:.5 } +.child-section { + border:1px solid #263e5c; + background:#0e1b2e; + border-radius:16px; + overflow:hidden; + margin-top:16px +} +.child-list { + padding:20px 22px; + display:grid; + gap:10px +} +.child-list h3 { + margin:10px 0 0; + font-size:13px; + color:#a9c9e9 +} +.child-card { + padding:12px 14px; + border:1px solid #35516e; + border-radius:10px; + background:#13243b; + display:grid; + gap:9px; + font-size:12px +} +.child-heading { + display:flex; + align-items:center; + justify-content:space-between; + gap:12px; + flex-wrap:wrap +} +.child-rail span { background:#8cddbe } +.child-card span { color:#94adc8 } +.child-card .child-identity { color:#b6d2ed } .error { margin-top:20px; padding:12px 14px; @@ -201,32 +296,6 @@ h1,h2 { .flow { padding:20px 22px } -.root-card { - padding:15px 17px; - border-radius:12px; - border:1px solid #4177af; - background:#163252; - margin-bottom:18px -} -.root-card .label { - display:block; - color:#91bde8; - font-size:11px; - text-transform:uppercase; - font-weight:800; - letter-spacing:.1em -} -.root-card strong { - display:block; - margin-top:5px; - font-size:16px; - overflow-wrap:anywhere -} -.root-card small { - display:block; - margin-top:5px; - color:#a4bbd5 -} .task-list { border-left:2px solid #38597c; margin-left:17px; @@ -421,12 +490,18 @@ h1,h2 { line-height:1.5; color:#ffd28f } -.footnote { +.data-notes { color:#859bb4; font-size:11px; line-height:1.6; margin:19px 1px 0 } +.data-notes summary { + cursor:pointer; + width:max-content; + color:#a9c9e9 +} +.data-notes p { margin:8px 0 0 } @media(max-width:850px) { main { padding:20px 16px diff --git a/plugins/huixin615/agent-flow/miniapp/client/app.js b/plugins/huixin615/agent-flow/miniapp/client/app.js index bceea8cd..e6337c59 100644 --- a/plugins/huixin615/agent-flow/miniapp/client/app.js +++ b/plugins/huixin615/agent-flow/miniapp/client/app.js @@ -1,294 +1,248 @@ -const dom = Object.fromEntries([ - 'session-select', 'selection-note', 'follow-latest', 'load-more-sessions', 'refresh-state', 'refresh-button', 'error', - 'total', 'running', 'done', 'failed', 'observation', 'flow', 'detail', -].map((id) => [id, document.getElementById(id)])); +import { buildTimeline, executionOverlap, parseEventFiles } from './flow.mjs'; -let selectedId = null; -let selectedTaskId = null; -let manualSelection = false; -let lastSignature = ''; -let currentFlow = null; -let requestSequence = 0; -let detailSequence = 0; -let lastDetailKey = ''; -let missingFinalRetries = 0; -let sessionLimit = 40; -let sessionSequence = 0; +const ids = ['event-files', 'file-note', 'error', 'session-select', 'latest-session', 'session-note', 'observation-panel', + 'total', 'running', 'done', 'linked', 'observation', 'flow', 'detail', 'children']; +const dom = Object.fromEntries(ids.map((id) => [id, document.getElementById(id)])); +let timeline = null; +let selectedSession = null; +let selectedTask = null; -function node(tag, className, text) { +function node(tag, className, value) { const element = document.createElement(tag); if (className) element.className = className; - if (text !== undefined) element.textContent = String(text); + if (value !== undefined) element.textContent = String(value); return element; } +const shortId = (id) => id ? `${id.slice(0, 10)}…` : '未记录'; function time(ms) { - return Number.isFinite(ms) && ms > 0 - ? new Date(ms).toLocaleTimeString('zh-CN', { hour12: false }) : '—'; + if (!Number.isFinite(ms) || ms <= 0) return '—'; + const date = new Date(ms); + const two = (value) => String(value).padStart(2, '0'); + return `${date.getFullYear()}-${two(date.getMonth() + 1)}-${two(date.getDate())} ` + + `${two(date.getHours())}:${two(date.getMinutes())}:${two(date.getSeconds())}`; } - -function duration(ms) { - if (!Number.isFinite(ms) || ms < 0) return '—'; - return ms < 1000 ? `${Math.round(ms)} 毫秒` : `${(ms / 1000).toFixed(1)} 秒`; -} - -const statusNames = { - queued: '排队', running: '运行中', stopping: '停止中', succeeded: '已完成', - failed: '失败', canceled: '已取消', lost: '中断', -}; - -async function getJson(path) { - let response; - try { response = await fetch(path, { cache: 'no-store' }); } - catch { throw new Error('插件运行时已断开。请点击上方 ▶ 启动,然后刷新页面。'); } - const data = await response.json(); - if (!response.ok) throw new Error(data.message || '读取失败'); - return data; +const duration = (start, end) => start != null && end != null + ? `${((end - start) / 1000).toFixed(1)} 秒` : '—'; +function timeRange(start, end) { + if (end == null) return `${time(start)} – —`; + const from = time(start); + const to = time(end); + return `${from} – ${to.slice(0, 10) === from.slice(0, 10) ? to.slice(11) : to}`; } -function showError(message) { - dom.error.hidden = !message; - dom.error.textContent = message || ''; +function fact(list, label, value) { + list.append(node('dt', '', label), node('dd', '', value)); } -async function loadSessions() { - const sequence = ++sessionSequence; - try { - const pin = selectedId ? `&pin=${encodeURIComponent(selectedId)}` : ''; - const { sessions, hasMore } = await getJson(`/api/sessions?limit=${sessionLimit}${pin}`); - if (sequence !== sessionSequence) return; - const previous = selectedId; - const newest = sessions[0]?.id ?? null; - if (!manualSelection || !sessions.some((session) => session.id === selectedId)) { - selectedId = newest; - if (selectedId !== previous) { - selectedTaskId = null; - lastDetailKey = ''; - missingFinalRetries = 0; - lastSignature = ''; - currentFlow = null; - for (const id of ['total', 'running', 'done', 'failed']) dom[id].textContent = '—'; - dom.flow.replaceChildren(node('div', 'empty', '正在读取所选会话…')); - dom.detail.replaceChildren(node('div', 'empty', '正在读取子任务…')); - } - manualSelection = false; - } - dom['session-select'].replaceChildren(...sessions.map((session) => { - const option = node('option', '', `${session.title}${session.taskCount ? ` · ${session.taskCount} 项子任务` : ''}`); - option.value = session.id; - option.title = session.id; - return option; - })); - dom['session-select'].disabled = sessions.length === 0; - if (selectedId) dom['session-select'].value = selectedId; - dom['follow-latest'].hidden = !manualSelection; - dom['load-more-sessions'].hidden = !hasMore || sessionLimit >= 10000; - dom['selection-note'].textContent = manualSelection - ? `手动选择的本机会话 · 列出 ${sessions.length} 条${hasMore ? sessionLimit >= 10000 ? ',已达展示上限' : ',还有更多' : ''}` - : `自动跟随最近更新的本机会话(推断,可能不是当前会话)· 列出 ${sessions.length} 条${hasMore ? sessionLimit >= 10000 ? ',已达展示上限' : ',还有更多' : ''}`; - showError(''); - if (!selectedId) { - dom.flow.replaceChildren(node('div', 'empty', '暂无可读取的本机会话。')); - dom.detail.replaceChildren(node('div', 'empty', '暂无子任务。')); - } else if (previous !== selectedId) await loadFlow(); - } catch (error) { - if (sequence === sessionSequence) showError(error.message); +function renderDetail(task) { + dom.detail.replaceChildren(); + if (!task) return dom.detail.append(node('div', 'empty', '选择一项 task 调用')); + dom.detail.append(node('h3', '', task.title || '未记录任务标题')); + dom.detail.append(node('p', 'subline', '仅保存任务标题,不保存完整指令或回复。')); + const facts = node('dl', 'facts'); + fact(facts, '开始调用', time(task.startAt)); + fact(facts, '工具回传', time(task.endAt)); + fact(facts, '调用耗时', duration(task.startAt, task.endAt)); + fact(facts, '回传状态', task.endAt === null ? '未观察到 PostToolUse' : task.resultPresent ? 'PostToolUse 带有结果' : '观察到 PostToolUse;无结果字段'); + fact(facts, '会话散列', shortId(task.session)); + fact(facts, '轮次散列', shortId(task.turn)); + fact(facts, '调用散列', task.id.includes(':') ? shortId(task.id.split(':').at(-1)) : '未记录'); + const child = timeline.children.find((item) => item.linkedTaskId === task.id); + if (child) { + fact(facts, '关联子 Agent', `${child.agentType || '子 Agent'} · ${shortId(child.session)}`); + fact(facts, '子 Agent 运行', `${timeRange(child.startAt, child.endAt)} · ${duration(child.startAt, child.endAt)}`); + fact(facts, '关联依据', 'task 回传中的子会话 ID 与子 Agent 事件中的会话 ID 一致'); + } else if (task.childSession) { + fact(facts, '关联子 Agent', '工具回传包含子会话 ID;导入记录中没有唯一匹配的启停事件'); } + dom.detail.append(facts); + dom.detail.append(node('p', 'warning', '工具回传不表示父 Agent 已使用或认可子 Agent 的结果。')); } -function addFact(list, label, value) { - list.append(node('dt', '', label), node('dd', '', value)); -} - -function addTextSection(container, title, text) { - const section = node('section', 'detail-section'); - const head = node('div', 'head'); - head.append(node('h4', '', title)); - if (text) { - const button = node('button', 'copy', '复制'); - button.type = 'button'; - button.addEventListener('click', async () => { - try { await navigator.clipboard.writeText(text); button.textContent = '已复制'; } - catch { button.textContent = '复制失败'; } - }); - head.append(button); +function childCard(child, visibleChildren) { + const card = node('div', 'child-card'); + const heading = node('div', 'child-heading'); + const task = timeline.tasks.find((item) => item.id === child.linkedTaskId); + heading.append(node('strong', '', task?.title || `${child.agentType || '子 Agent'} · ${shortId(child.session)}`)); + heading.append(node('span', '', `${timeRange(child.startAt, child.endAt)} · ${duration(child.startAt, child.endAt)}`)); + card.append(heading); + if (task) card.append(node('span', 'child-identity', `${child.agentType || '子 Agent'} · ${shortId(child.session)}`)); + if (child.startAt !== null && child.endAt !== null) { + const complete = visibleChildren.filter((item) => item.startAt !== null && item.endAt !== null); + const first = Math.min(...complete.map((item) => item.startAt)); + const last = Math.max(...complete.map((item) => item.endAt)); + const span = Math.max(1, last - first); + const rail = node('div', 'rail child-rail'); + const bar = node('span'); + bar.style.marginLeft = `${(child.startAt - first) / span * 100}%`; + bar.style.width = `${Math.max(1, (child.endAt - child.startAt) / span * 100)}%`; + rail.append(bar); + card.append(rail); } - section.append(head); - section.append(text ? node('pre', '', text) : node('p', 'none', '当前记录中没有可展示的内容。')); - container.append(section); + card.append(node('span', '', child.endAt ? '观察到停止' : '未观察到停止')); + return card; } -function renderDetail(task) { - const detail = dom.detail; - detail.replaceChildren(); - if (!task) { - detail.append(node('div', 'empty', '选择一项子任务')); +function renderChildren() { + const linked = timeline.children.filter((child) => selectedSession && child.linkSource && !child.relationConflict && child.parentSession === selectedSession); + dom.linked.textContent = linked.length; + dom.children.replaceChildren(); + if (!selectedSession) { + dom.children.append(node('div', 'empty', '选择一条导入记录中的会话后查看关联的子 Agent。')); return; } - detail.append(node('h3', '', task.name)); - detail.append(node('div', 'subline', `${task.agent} · ${statusNames[task.status] || task.status} · ${task.mode}`)); - const facts = node('dl', 'facts'); - addFact(facts, '创建', time(task.createdAt)); - addFact(facts, '开始运行', time(task.startedAt)); - addFact(facts, '结束', time(task.endedAt)); - addFact(facts, '耗时', duration(task.startedAt && task.endedAt ? task.endedAt - task.startedAt : null)); - const parentState = task.parentState === 'matched' ? '已在父会话历史中找到回传' - : task.parentState === 'unreadable' ? '父会话历史暂不可读,无法核实' - : task.parentState === 'unverified' ? '任务标记已交付;当前历史未找到回传(可能已压缩)' - : '尚未交付'; - addFact(facts, '父会话结果', parentState); - addFact(facts, '子会话', task.childId ? `${task.childId.slice(0, 12)}…` : '尚未建立'); - detail.append(facts); - if (task.warning || task.historyWarning) detail.append(node('p', 'warning', task.warning || task.historyWarning)); - addTextSection(detail, '委派指令(父 Agent 发出的 task 参数)', task.instruction); - addTextSection(detail, '子 Agent 最终回复', task.finalText); - const section = node('section', 'detail-section'); - section.append(node('h4', '', `子 Agent 工具调用 · ${task.tools.length}`)); - const tools = node('div', 'tools'); - for (const name of task.tools) tools.append(node('span', 'tool-pill', name)); - section.append(task.tools.length ? tools : node('p', 'none', '尚无工具调用记录。')); - detail.append(section); -} - -async function loadDetail(task) { - if (!task) { renderDetail(null); return; } - const key = `${selectedId}:${task.id}:${task.status}:${task.endedAt}:${task.updatedAt}:${task.parentState}:${task.historyWarning}`; - if (key === lastDetailKey) return; - lastDetailKey = key; - const sequence = ++detailSequence; - dom.detail.replaceChildren(node('div', 'empty', '正在读取子会话详情…')); - try { - const detail = await getJson(`/api/task?id=${encodeURIComponent(selectedId)}&task=${encodeURIComponent(task.id)}`); - if (sequence === detailSequence && selectedTaskId === task.id) { - missingFinalRetries = detail.childId && !detail.finalText ? missingFinalRetries + 1 : 0; - renderDetail(detail); - } - } catch (error) { - if (sequence === detailSequence) { - lastDetailKey = ''; - dom.detail.replaceChildren(node('div', 'empty', error.message)); - } + if (!linked.length) { + dom.children.append(node('div', 'empty', '所选会话没有可确认关联的子 Agent 启停事件。')); + return; } + for (const child of linked) dom.children.append(childCard(child, linked)); } -function renderFlow(flow) { - const tasks = flow.tasks; +function render() { + if (!timeline) return; + const tasks = timeline.tasks.filter((task) => task.session === selectedSession); dom.total.textContent = tasks.length; - dom.running.textContent = tasks.filter((task) => ['queued', 'running', 'stopping'].includes(task.status)).length; - dom.done.textContent = tasks.filter((task) => task.status === 'succeeded').length; - dom.failed.textContent = tasks.filter((task) => ['failed', 'canceled', 'lost'].includes(task.status)).length; + dom.running.textContent = tasks.filter((task) => task.endAt === null).length; + dom.done.textContent = tasks.filter((task) => task.endAt !== null).length; const notes = []; - for (const group of flow.analysis?.sameTurnGroups ?? []) { - const mode = group.execution === 'serial' ? '运行区间没有重叠(实际串行)' - : group.execution === 'overlap' ? '运行区间有重叠' - : '尚有任务未结束,暂不判断并行关系'; - notes.push(`同一轮委派的 ${group.count} 项子任务:${mode}${group.wallMs === undefined ? '' : `;跨度 ${duration(group.wallMs)}`}`); - } - if (tasks.length) { - const uncertain = tasks.filter((task) => ['unverified', 'unreadable'].includes(task.parentState)).length; - notes.push(`${flow.analysis?.parentResults ?? 0}/${tasks.length} 项回传可在当前父会话历史中核实${uncertain ? `;${uncertain} 项暂无法核实(可能已压缩或读取失败)` : ''}。匹配不代表父 Agent 已采用。`); + if (selectedSession) { + const selectedChildren = timeline.children.filter((child) => child.linkSource && !child.relationConflict && child.parentSession === selectedSession); + const completeSelected = selectedChildren.filter((child) => child.startAt !== null && child.endAt !== null); + if (completeSelected.length > 1) notes.push(executionOverlap(completeSelected) + ? '已关联的子 Agent 运行区间存在重叠。' + : '已关联的子 Agent 运行区间没有重叠。'); + else if (selectedChildren.length === 0 && tasks.length) notes.push('尚无可确认关联的子 Agent 运行记录。'); } - dom.observation.replaceChildren(...(notes.length ? notes : ['暂无可分析的子任务。']).map((note) => node('p', '', note))); - const root = node('div', 'root-card'); - root.append(node('span', 'label', '父 Agent · 当前观察会话')); - root.append(node('strong', '', flow.session.title)); - root.append(node('small', '', `更新于 ${time(flow.session.updatedAt)}`)); - dom.flow.replaceChildren(root); - if (tasks.length === 0) { - dom.flow.append(node('div', 'empty', '这条会话尚无原生 task 子 Agent 记录。\n用户可以照常在 MiniMax Code 对话,插件会在打开期间刷新。')); + if (!selectedSession) notes.push('选择一条导入记录中的会话,或查看最近记录。'); + const orphanResults = timeline.orphanResults.filter((row) => row.session === selectedSession); + if (orphanResults.length) notes.push(`${orphanResults.length} 条 PostToolUse 缺少可匹配的 PreToolUse,未计入任务。`); + dom.observation.replaceChildren(...notes.map((value) => node('p', '', value))); + dom['observation-panel'].hidden = notes.length === 0; + dom.flow.replaceChildren(); + if (!tasks.length) { + dom.flow.append(node('div', 'empty', selectedSession + ? '所选会话没有已记录的 task 调用。Hook 只记录安装后的事件。' + : '请选择导入记录中的会话;插件无法确认当前聊天对应哪条记录。')); + selectedTask = null; renderDetail(null); - return; - } - const list = node('div', 'task-list'); - const now = Date.now(); - const first = Math.min(...tasks.map((task) => task.createdAt || task.startedAt || now)); - const last = Math.max(...tasks.map((task) => task.endedAt || (task.startedAt && ['running', 'stopping'].includes(task.status) ? now : task.createdAt || first))); - const span = Math.max(1, last - first); - for (const task of tasks) { - const button = node('button', `task-card ${task.status}${task.depth ? ' nested' : ''}${task.id === selectedTaskId ? ' selected' : ''}`); - button.type = 'button'; - button.style.setProperty('--depth', String(task.depth || 0)); - const top = node('div', 'task-top'); - top.append(node('span', 'task-title', task.name), node('span', `status ${task.status}`, statusNames[task.status] || task.status)); - const meta = node('div', 'task-meta'); - meta.append(node('span', '', `${task.agent} · ${task.mode}${task.parentTaskId ? ' · 子任务' : ''}`), node('span', '', task.startedAt ? `${time(task.startedAt)} – ${time(task.endedAt)}` : `创建 ${time(task.createdAt)} · 尚未开始`)); - const rail = node('div', 'rail'); - const bar = node('span'); - if (task.startedAt && (task.endedAt || ['running', 'stopping'].includes(task.status))) { - bar.style.marginLeft = `${Math.max(0, Math.min(100, (task.startedAt - first) / span * 100))}%`; - bar.style.width = `${Math.max(1, Math.min(100, ((task.endedAt || now) - task.startedAt) / span * 100))}%`; - } else bar.hidden = true; - rail.append(bar); - const handoff = task.parentState === 'matched' ? '回传已在父会话中找到' - : task.parentState === 'unverified' ? '已交付;当前历史无法核实回传' - : task.parentState === 'unreadable' ? '父会话历史不可读' : '等待结果'; - button.append(top, meta, rail, node('div', 'flow-arrow', handoff)); - button.addEventListener('click', () => { - selectedTaskId = task.id; - renderFlow(flow); - }); - list.append(button); + } else { + if (!tasks.some((task) => task.id === selectedTask)) selectedTask = tasks[0].id; + const list = node('div', 'task-list'); + const first = Math.min(...tasks.map((task) => task.startAt)); + const last = Math.max(...tasks.map((task) => task.endAt ?? task.startAt)); + const span = Math.max(1, last - first); + for (const task of tasks) { + const status = task.endAt === null ? 'running' : 'succeeded'; + const button = node('button', `task-card ${status}${selectedTask === task.id ? ' selected' : ''}`); + button.type = 'button'; + const top = node('div', 'task-top'); + top.append(node('span', 'task-title', task.title || '未记录任务标题')); + top.append(node('span', `status ${status}`, task.endAt === null ? '未见工具回传' : '工具已回传')); + const meta = node('div', 'task-meta'); + meta.append(node('span', '', timeRange(task.startAt, task.endAt)), node('span', '', duration(task.startAt, task.endAt))); + const rail = node('div', 'rail'); + const bar = node('span'); + if (task.endAt === null) bar.hidden = true; + else { + bar.style.marginLeft = `${(task.startAt - first) / span * 100}%`; + bar.style.width = `${Math.max(1, (task.endAt - task.startAt) / span * 100)}%`; + } + rail.append(bar); + const child = timeline.children.find((item) => item.linkedTaskId === task.id); + const relation = child ? `关联子 Agent:${child.agentType || '子 Agent'} · ${shortId(child.session)} · ${duration(child.startAt, child.endAt)}` + : task.childSession ? '回传含子会话 ID;尚无唯一匹配的启停事件' + : task.endAt === null ? '尚无 PostToolUse' : '观察到 PostToolUse'; + button.append(top, meta, rail, node('div', 'flow-arrow', relation)); + button.addEventListener('click', () => { selectedTask = task.id; render(); }); + list.append(button); + } + dom.flow.append(list); + renderDetail(tasks.find((task) => task.id === selectedTask)); } - dom.flow.append(list); - if (!tasks.some((task) => task.id === selectedTaskId)) selectedTaskId = tasks[0].id; - void loadDetail(tasks.find((task) => task.id === selectedTaskId)); + renderChildren(); +} + +function updateSessionSelection() { + if (!timeline) return; + if (!timeline.sessions.some((session) => session.id === selectedSession)) selectedSession = null; + const visible = timeline.sessions.filter((session) => session.taskCount > 0); + const placeholder = node('option', '', '选择导入记录中的会话'); + placeholder.value = ''; + dom['session-select'].replaceChildren(placeholder, ...visible.map((session) => { + const option = node('option', '', `${time(session.lastAt)} · ${session.taskCount} 次 task 调用 · ${shortId(session.id)}`); + option.value = session.id; + return option; + })); + dom['session-select'].disabled = visible.length === 0; + dom['latest-session'].disabled = visible.length === 0; + dom['session-select'].value = selectedSession ?? ''; + dom['session-note'].textContent = selectedSession + ? '已选择导入记录中的会话;不一定是当前聊天。' + : '选择会话,或查看最近记录;插件无法识别当前聊天。'; + render(); } -async function loadFlow() { - if (!selectedId) return; - const id = selectedId; - const sequence = ++requestSequence; +function importError(message) { + timeline = null; + selectedSession = null; + selectedTask = null; + dom['session-select'].replaceChildren(); + dom['session-select'].disabled = true; + dom['latest-session'].disabled = true; + dom['file-note'].textContent = '导入失败;未显示记录'; + dom['observation-panel'].hidden = true; + for (const id of ['total', 'running', 'done', 'linked']) dom[id].textContent = '0'; + dom.observation.textContent = '导入记录后显示时间关系。'; + dom.flow.replaceChildren(node('div', 'empty', '请选择插件生成的记录文件。')); + dom.detail.replaceChildren(node('div', 'empty', '选择一项 task 调用')); + dom.children.replaceChildren(node('div', 'empty', '导入后选择会话,查看明确关联的子 Agent。')); + dom.error.textContent = message; + dom.error.hidden = false; + dom['session-note'].textContent = '导入后选择会话,或查看最近记录。'; +} + +function loadTexts(texts, label) { try { - const flow = await getJson(`/api/flow?id=${encodeURIComponent(id)}`); - if (sequence !== requestSequence || id !== selectedId) return; - currentFlow = flow; - const signature = JSON.stringify({ session: flow.session, tasks: flow.tasks }); - if (signature !== lastSignature || flow.tasks.some((task) => ['running', 'stopping'].includes(task.status))) { - lastSignature = signature; - renderFlow(flow); - } - dom['refresh-state'].textContent = `已更新 ${time(flow.observedAt)}`; - showError(''); + const rows = parseEventFiles(texts); + timeline = buildTimeline(rows); + selectedTask = null; + selectedSession = null; + dom['file-note'].textContent = label; + dom.error.hidden = true; + updateSessionSelection(); } catch (error) { - if (sequence !== requestSequence) return; - dom['refresh-state'].textContent = '等待重试'; - showError(error.message); + importError(error.message); } } -dom['session-select'].addEventListener('change', () => { - selectedId = dom['session-select'].value; - selectedTaskId = null; - lastDetailKey = ''; - missingFinalRetries = 0; - manualSelection = true; - dom['follow-latest'].hidden = false; - lastSignature = ''; - dom['selection-note'].textContent = '手动选择的本机会话'; - void loadFlow(); -}); -dom['follow-latest'].addEventListener('click', () => { - manualSelection = false; - void loadSessions(); +dom['event-files'].addEventListener('change', async () => { + const files = [...dom['event-files'].files]; + if (!files.length) return; + if (files.reduce((size, file) => size + file.size, 0) > 12 * 1024 * 1024) { + importError('记录文件合计超过 12 MiB,请分批选择。'); + dom['event-files'].value = ''; + return; + } + try { loadTexts(await Promise.all(files.map((file) => file.text())), `已导入 ${files.map((file) => file.name).join('、')} · 当前快照`); } + catch { importError('读取所选文件失败。'); } + dom['event-files'].value = ''; }); -dom['refresh-button'].addEventListener('click', () => { - lastSignature = ''; - lastDetailKey = ''; - missingFinalRetries = 0; - void loadSessions().then(loadFlow); + +dom['session-select'].addEventListener('change', () => { + selectedSession = dom['session-select'].value || null; + selectedTask = null; + if (!selectedSession) return updateSessionSelection(); + dom['session-note'].textContent = '已选择导入记录中的会话;不一定是当前聊天。'; + render(); }); -dom['load-more-sessions'].addEventListener('click', () => { - sessionLimit = Math.min(10000, sessionLimit + 40); - void loadSessions(); + +dom['latest-session'].addEventListener('click', () => { + const latest = timeline?.sessions.find((item) => item.taskCount > 0); + if (!latest) return; + selectedSession = latest.id; + selectedTask = null; + dom['session-select'].value = latest.id; + dom['session-note'].textContent = '最近有 task 调用的会话(推断);可能不是当前聊天。'; + render(); }); -void loadSessions(); -setInterval(() => { if (document.visibilityState === 'visible') void loadFlow(); }, 3000); -setInterval(() => { if (document.visibilityState === 'visible') void loadSessions(); }, 15000); -setInterval(() => { - if (document.visibilityState !== 'visible' || !currentFlow) return; - const task = currentFlow.tasks.find((item) => item.id === selectedTaskId); - if (task && (['queued', 'running', 'stopping'].includes(task.status) || (missingFinalRetries > 0 && missingFinalRetries < 6))) { - lastDetailKey = ''; - void loadDetail(task); - } -}, 10000); diff --git a/plugins/huixin615/agent-flow/miniapp/client/flow.mjs b/plugins/huixin615/agent-flow/miniapp/client/flow.mjs new file mode 100644 index 00000000..31249b77 --- /dev/null +++ b/plugins/huixin615/agent-flow/miniapp/client/flow.mjs @@ -0,0 +1,133 @@ +const EVENTS = new Set(['SessionStart', 'PreToolUse', 'PostToolUse', 'SubagentStart', 'SubagentStop']); +const HEX = /^[a-f0-9]{64}$/; +const MAX_BYTES = 12 * 1024 * 1024; +const MAX_ROWS = 30_000; + +function hash(value) { return value === null || (typeof value === 'string' && HEX.test(value)); } + +export function parseEventFiles(texts) { + if (!Array.isArray(texts) || texts.length === 0) throw new Error('请选择插件生成的 JSONL 记录文件。'); + const rows = []; + const seen = new Set(); + let bytes = 0; + for (const text of texts) { + if (typeof text !== 'string') throw new Error('记录文件不是文本。'); + bytes += new TextEncoder().encode(text).length; + if (bytes > MAX_BYTES) throw new Error('记录文件合计超过 12 MiB,请只选择需要查看的文件。'); + const lines = text.split(/\r?\n/); + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index].trim(); + if (!line) continue; + if (line.length > 4096) throw new Error(`第 ${index + 1} 行过长,已停止导入。`); + let row; + try { row = JSON.parse(line); } + catch { throw new Error(`第 ${index + 1} 行不是完整的 JSON。请稍后重新选择文件。`); } + if (row?.schema !== 'agent-flow-hook/v1' || typeof row.id !== 'string' + || row.id.length > 80 || !EVENTS.has(row.event) || !Number.isSafeInteger(row.at) + || row.at <= 0 || !HEX.test(row.session) || !hash(row.parentSession) + || !hash(row.turn) || !hash(row.agent) || !hash(row.toolUse) + || (row.childSession !== undefined && !hash(row.childSession)) + || (row.taskTitle != null && (typeof row.taskTitle !== 'string' || row.taskTitle.length > 160)) + || (row.agentType != null && (typeof row.agentType !== 'string' || row.agentType.length > 160))) { + throw new Error(`第 ${index + 1} 行不是受支持的 Agent Flow 事件。`); + } + if (!seen.has(row.id)) { + seen.add(row.id); + rows.push({ + id: row.id, at: row.at, event: row.event, session: row.session, + parentSession: row.parentSession, turn: row.turn, agent: row.agent, + agentType: row.agentType, toolUse: row.toolUse, childSession: row.childSession ?? null, + taskTitle: row.taskTitle, + resultPresent: row.resultPresent === true, + }); + if (rows.length > MAX_ROWS) throw new Error('事件数超过 30000,请分批导入。'); + } + } + } + rows.sort((a, b) => a.at - b.at); + return rows; +} + +export function buildTimeline(rows) { + const sessions = new Map(); + const tasks = new Map(); + const children = new Map(); + const orphanResults = []; + for (const row of rows) { + const session = sessions.get(row.session) ?? { id: row.session, firstAt: row.at, lastAt: row.at, taskCount: 0 }; + session.firstAt = Math.min(session.firstAt, row.at); + session.lastAt = Math.max(session.lastAt, row.at); + sessions.set(row.session, session); + if (row.event === 'PreToolUse') { + const key = row.toolUse ? `${row.session}:${row.toolUse}` : row.id; + if (!tasks.has(key)) { + tasks.set(key, { id: key, session: row.session, turn: row.turn, title: row.taskTitle, + startAt: row.at, endAt: null, resultPresent: false, childSession: null, + childSessionConflict: false }); + session.taskCount += 1; + } + } else if (row.event === 'PostToolUse') { + const task = row.toolUse ? tasks.get(`${row.session}:${row.toolUse}`) : null; + if (task) { + task.endAt = Math.max(task.startAt, row.at); + task.resultPresent ||= row.resultPresent; + if (row.childSession) { + if (task.childSession && task.childSession !== row.childSession) task.childSessionConflict = true; + else task.childSession = row.childSession; + } + } else orphanResults.push(row); + } else if (row.event === 'SubagentStart' || row.event === 'SubagentStop') { + const key = row.session; + const child = children.get(key) ?? { id: key, session: row.session, + parentSession: row.parentSession, agentType: row.agentType, + startAt: null, endAt: null, linkedTaskId: null, linkSource: null, + relationConflict: false }; + if (row.parentSession) { + if (child.parentSession && child.parentSession !== row.parentSession) child.relationConflict = true; + else child.parentSession = row.parentSession; + } + if (row.agentType) child.agentType = row.agentType; + if (row.event === 'SubagentStart') child.startAt = Math.min(child.startAt ?? row.at, row.at); + else child.endAt = Math.max(child.endAt ?? row.at, row.at); + children.set(key, child); + } + } + const tasksByChild = new Map(); + for (const task of tasks.values()) { + if (task.childSession && !task.childSessionConflict) { + const matches = tasksByChild.get(task.childSession) ?? []; + matches.push(task); + tasksByChild.set(task.childSession, matches); + } + } + for (const child of children.values()) { + const matches = tasksByChild.get(child.session) ?? []; + if (matches.length === 1) { + const [task] = matches; + if (child.parentSession && child.parentSession !== task.session) child.relationConflict = true; + if (!child.relationConflict) { + child.parentSession = task.session; + child.linkedTaskId = task.id; + child.linkSource = 'task-result'; + } + } else if (matches.length > 1) child.relationConflict = true; + if (!child.linkSource && child.parentSession && !child.relationConflict) child.linkSource = 'hook-parent'; + } + return { + sessions: [...sessions.values()].sort((a, b) => b.lastAt - a.lastAt), + tasks: [...tasks.values()].sort((a, b) => a.startAt - b.startAt), + children: [...children.values()].sort((a, b) => (a.startAt ?? a.endAt) - (b.startAt ?? b.endAt)), + orphanResults, + eventCount: rows.length, + }; +} + +export function executionOverlap(tasks) { + const complete = tasks.filter((task) => task.endAt !== null); + for (let i = 0; i < complete.length; i += 1) { + for (let j = i + 1; j < complete.length; j += 1) { + if (complete[i].startAt < complete[j].endAt && complete[j].startAt < complete[i].endAt) return true; + } + } + return false; +} diff --git a/plugins/huixin615/agent-flow/miniapp/client/index.html b/plugins/huixin615/agent-flow/miniapp/client/index.html index d03f7d3d..1569b595 100644 --- a/plugins/huixin615/agent-flow/miniapp/client/index.html +++ b/plugins/huixin615/agent-flow/miniapp/client/index.html @@ -6,43 +6,58 @@ Agent 协作透视 - +
-
Agent 协作透视原生子 Agent · 本机只读观测
- ● 本机数据 +
Agent 协作透视任务调用与子 Agent
+ ● 本机处理
-
-
观察会话
-

自动跟随最近更新的本机会话(推断)

- -
-
读取中…
+
+
+
EVENT IMPORT
+

导入协作记录

+

选择插件生成的 agent-flow-events.jsonl,查看 task 调用与子 Agent 的运行时间。

+

文件只在本机解析;查看新增事件时,请重新选择文件。支持同时选择轮转文件。

+
+
+ + +
+
尚未选择记录文件
-
-
子任务—
-
进行中(含排队)—
-
已完成—
-
失败或中断—
+
+
观察会话
+

导入后选择会话,或查看最近记录。

-
- 执行观察
正在读取任务时间和结果记录…
+
+
task 调用0
+
未观察到回传0
+
已观察到回传0
+
关联子 Agent(所选会话)0
+
执行观察
导入记录后显示时间关系。
-
EXECUTION FLOW

协作时间线

点击任务查看证据
-
正在读取本机会话…
+
TASK CALLS

task 工具调用

仅显示工具调用耗时
+
请选择插件生成的记录文件。
-
-

状态来自本机保存的任务与会话记录;“已交付”是任务记录状态,父会话回传须另行核实。页面打开期间定期刷新。

+
+
SUBAGENT EVENTS

关联子 Agent

+
导入后选择会话,查看明确关联的子 Agent。
+
+
数据与关联说明 +

只展示所选记录会话中的 task 调用,以及能通过明确 ID 关联到它的子 Agent 事件;不按时间接近程度猜测关联。

+

task 回传表示工具调用结束,不表示父 Agent 已采纳结果。页面只解析你选择的记录文件,不扫描其他会话文件。

+
diff --git a/plugins/huixin615/agent-flow/miniapp/node/data.mjs b/plugins/huixin615/agent-flow/miniapp/node/data.mjs deleted file mode 100644 index 49772e6b..00000000 --- a/plugins/huixin615/agent-flow/miniapp/node/data.mjs +++ /dev/null @@ -1,366 +0,0 @@ -// Read-only adapter for the local runtime layout observed in MiniMax Code 3.1.4. -import { readFile, realpath, stat } from 'node:fs/promises'; -import { homedir } from 'node:os'; -import { join, relative, resolve, sep } from 'node:path'; - -const SESSION_ID = /^mvs_[a-zA-Z0-9]+$/; -const TASK_ID = /^bg_[a-zA-Z0-9-]+$/; -const RELATIVE_DIR = /^20\d{2}\/\d{2}\/\d{2}\/[a-zA-Z0-9_-]+-session_[a-zA-Z0-9_-]+$/; -const MAX_HISTORY_BYTES = 8 * 1024 * 1024; -const MAX_TREE_TASKS = 2000; -const MAX_TREE_DEPTH = 8; -const HISTORY_CACHE_SIZE = 8; -const historyCache = new Map(); -const parentEvidenceCache = new WeakMap(); - -function timestamp(value) { - const number = Number(value); - return Number.isFinite(number) && number > 0 ? number : null; -} - -export class DataError extends Error { - constructor(code, message) { - super(message); - this.code = code; - } -} - -export function dataHome(env = process.env) { - return typeof env.MAVIS_HOME === 'string' && env.MAVIS_HOME.trim() - ? resolve(env.MAVIS_HOME.trim()) - : join(homedir(), '.minimax'); -} - -async function withDb(home, use) { - let DatabaseSync; - try { - ({ DatabaseSync } = await import('node:sqlite')); - } catch { - throw new DataError('SQLITE_UNAVAILABLE', '当前 Node 运行时无法读取本机会话数据库。'); - } - let db; - try { - db = new DatabaseSync(join(home, 'v2', 'sqlite', 'runtime-state.sqlite'), { readOnly: true }); - return use(db); - } catch (error) { - if (error instanceof DataError) throw error; - throw new DataError('STORE_UNAVAILABLE', '本机会话数据库不可用或格式不兼容。'); - } finally { - db?.close(); - } -} - -function sessionRow(db, id) { - return db.prepare(` - SELECT session_id AS id, parent_session_id AS parentId, session_kind AS kind, - title, status, created_at_ms AS createdAt, updated_at_ms AS updatedAt, - history_relative_dir AS historyDir - FROM local_runtime_sessions - WHERE session_id = ? - `).get(id); -} - -export async function listConversations(home = dataHome(), limit = 40, pinnedId = null) { - if (!Number.isInteger(limit) || limit < 1 || limit > 10000) { - throw new DataError('INVALID_LIMIT', '会话列表数量无效。'); - } - if (pinnedId !== null && !SESSION_ID.test(pinnedId)) { - throw new DataError('INVALID_SESSION', '会话 ID 格式无效。'); - } - const { rows, pinned } = await withDb(home, (db) => { - const rows = db.prepare(` - SELECT s.session_id AS id, s.title, s.status, - s.created_at_ms AS createdAt, s.updated_at_ms AS updatedAt, - (SELECT COUNT(*) FROM local_runtime_background_tasks t - WHERE t.kind = 'subagent' AND t.owner_session_id = s.session_id) AS taskCount - FROM local_runtime_sessions s - WHERE s.session_kind = 'conversation' AND s.parent_session_id IS NULL - ORDER BY s.updated_at_ms DESC - LIMIT ? - `).all(limit + 1); - let pinned = null; - if (pinnedId && !rows.some((row) => row.id === pinnedId)) { - const session = sessionRow(db, pinnedId); - if (session?.kind === 'conversation' && session.parentId === null) { - const count = db.prepare(`SELECT COUNT(*) AS count FROM local_runtime_background_tasks - WHERE kind = 'subagent' AND owner_session_id = ?`).get(pinnedId).count; - pinned = { id: session.id, title: session.title, status: session.status, - createdAt: session.createdAt, updatedAt: session.updatedAt, taskCount: count }; - } - } - return { rows, pinned }; - }); - const sessions = rows.slice(0, limit).filter((row) => SESSION_ID.test(row.id)) - .map((row) => ({ ...row, title: row.title || '未命名会话' })); - if (pinned && !sessions.some((row) => row.id === pinned.id)) { - sessions.push({ ...pinned, title: pinned.title || '未命名会话' }); - } - return { - sessions, - hasMore: rows.length > limit, - }; -} - -export function parseTaskRow(raw) { - let record; - try { record = JSON.parse(raw); } catch { return null; } - if (record?.kind !== 'subagent' || !TASK_ID.test(record.taskId) || !SESSION_ID.test(record.ownerSessionId)) return null; - const meta = record.metadata ?? {}; - const childId = typeof meta.childSessionId === 'string' && SESSION_ID.test(meta.childSessionId) ? meta.childSessionId : null; - const task = { - id: record.taskId, - ownerId: record.ownerSessionId, - childId, - parentTurnId: typeof meta.parentTurnId === 'string' ? meta.parentTurnId : null, - toolCallId: typeof record.toolCallId === 'string' ? record.toolCallId : null, - name: typeof record.description === 'string' ? record.description : '未命名委派', - agent: typeof meta.agentName === 'string' ? meta.agentName : '未知 Agent', - mode: typeof meta.executionMode === 'string' ? meta.executionMode : '未知', - status: typeof record.status === 'string' ? record.status : 'unknown', - createdAt: timestamp(record.createdAt), - startedAt: timestamp(record.startedAt), - endedAt: timestamp(record.endedAt), - deliveredAt: timestamp(record.deliveredAt), - updatedAt: timestamp(record.updatedAt), - }; - if (typeof meta.parentSessionId === 'string' && meta.parentSessionId !== task.ownerId) { - task.warning = '任务记录中的父会话 ID 不一致'; - task.childId = null; - } - return task; -} - -function loadTaskTree(db, rootId) { - const root = sessionRow(db, rootId); - if (!root || root.kind !== 'conversation' || root.parentId !== null) { - throw new DataError('SESSION_NOT_FOUND', '没有找到这条本机会话。'); - } - const sessions = new Map([[rootId, root]]); - const tasks = []; - const seen = new Set([rootId]); - const parentTasks = new Map(); - let frontier = [rootId]; - const byOwner = db.prepare(`SELECT record_json FROM local_runtime_background_tasks - WHERE kind = 'subagent' AND owner_session_id = ? ORDER BY created_at_ms ASC LIMIT ?`); - for (let depth = 0; frontier.length; depth += 1) { - const next = []; - for (const ownerId of frontier) { - const rows = byOwner.all(ownerId, MAX_TREE_TASKS + 1); - if (depth === MAX_TREE_DEPTH && rows.length) { - throw new DataError('TASK_DEPTH_LIMIT', '子 Agent 嵌套超过当前支持的层数,无法完整展示。'); - } - for (const row of rows) { - const task = parseTaskRow(row.record_json); - if (!task || task.ownerId !== ownerId) { - throw new DataError('TASK_RECORD_INVALID', '子任务记录与所属会话不一致或格式不兼容。'); - } - task.depth = depth; - task.parentTaskId = parentTasks.get(ownerId) ?? null; - tasks.push(task); - if (tasks.length > MAX_TREE_TASKS) { - throw new DataError('TASK_LIMIT', '当前会话的子任务过多,无法完整展示。'); - } - if (!task.childId) continue; - const child = sessions.get(task.childId) ?? sessionRow(db, task.childId); - if (!child) { - task.warning = '子会话索引尚未出现,等待刷新'; - continue; - } - if (child.kind !== 'task' || child.parentId !== ownerId) { - task.warning = '子会话的父会话与任务记录不一致'; - task.childId = null; - continue; - } - if (parentTasks.has(child.id) && parentTasks.get(child.id) !== task.id) { - task.warning = '同一子会话关联了多项任务,关系无法核实'; - task.childId = null; - continue; - } - sessions.set(child.id, child); - if (!seen.has(child.id)) { - seen.add(child.id); - parentTasks.set(child.id, task.id); - next.push(child.id); - } - } - } - frontier = next; - } - return { root, sessions, tasks: tasks.sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0)) }; -} - -async function historyRows(home, session) { - if (!session || !SESSION_ID.test(session.id) || typeof session.historyDir !== 'string' || !RELATIVE_DIR.test(session.historyDir)) { - throw new DataError('HISTORY_PATH_INVALID', '会话历史路径格式不符合已验证版本。'); - } - const token = session.historyDir.split('-session_').at(-1); - if (token !== Buffer.from(session.id, 'utf8').toString('base64url')) { - throw new DataError('HISTORY_PATH_INVALID', '会话历史目录与会话 ID 不一致。'); - } - const root = await realpath(resolve(home, 'v2', 'sessions')); - const file = resolve(root, session.historyDir, 'messages.jsonl'); - if (!file.startsWith(root + sep) || relative(root, file).startsWith('..')) { - throw new DataError('HISTORY_PATH_INVALID', '会话历史路径超出本机会话目录。'); - } - const actual = await realpath(file); - if (!actual.startsWith(root + sep)) throw new DataError('HISTORY_PATH_INVALID', '会话历史文件指向目录外。'); - if (actual !== file) throw new DataError('HISTORY_PATH_INVALID', '会话历史路径包含未验证的符号链接。'); - const info = await stat(actual); - if (info.size > MAX_HISTORY_BYTES) throw new DataError('HISTORY_TOO_LARGE', '会话历史较大,暂不加载消息详情。'); - const cached = historyCache.get(actual); - if (cached?.size === info.size && cached?.mtimeMs === info.mtimeMs) { - historyCache.delete(actual); - historyCache.set(actual, cached); - return cached.rows; - } - const raw = await readFile(actual, 'utf8'); - const lines = raw.split('\n').filter(Boolean); - if (lines.length > 4000) throw new DataError('HISTORY_TOO_LARGE', '会话消息过多,暂不加载详情。'); - const rows = lines.map((line) => { - try { return JSON.parse(line); } - catch { throw new DataError('HISTORY_INVALID', '会话历史包含不完整或损坏的记录。'); } - }); - historyCache.delete(actual); - historyCache.set(actual, { size: info.size, mtimeMs: info.mtimeMs, rows }); - if (historyCache.size > HISTORY_CACHE_SIZE) historyCache.delete(historyCache.keys().next().value); - return rows; -} - -function plainText(message) { - if (!Array.isArray(message?.content)) return ''; - return message.content.filter((part) => part?.type === 'text' && typeof part.text === 'string') - .map((part) => part.text).join('\n'); -} - -export function extractTaskEvidence(parentRows, childRows, toolCallId) { - const { instruction, parentResult } = parentEvidenceByCall(parentRows).get(toolCallId) - ?? { instruction: '', parentResult: false }; - let finalText = ''; - const tools = []; - for (const row of childRows) { - const message = row?.message; - if (message?.role !== 'assistant') continue; - const calls = Array.isArray(message.content) - ? message.content.filter((part) => part?.type === 'toolCall' && typeof part.name === 'string') : []; - for (const call of calls) tools.push(call.name); - } - const last = childRows.at(-1)?.message; - if (last?.role === 'assistant' && !last.content?.some?.((part) => part?.type === 'toolCall')) { - finalText = plainText(last); - } - return { instruction, finalText, parentResult, tools }; -} - -function parentEvidenceByCall(rows) { - if (parentEvidenceCache.has(rows)) return parentEvidenceCache.get(rows); - const calls = new Map(); - for (const row of rows) { - const message = row?.message; - if (message?.role === 'assistant' && Array.isArray(message.content)) { - for (const part of message.content) { - if (part?.type === 'toolCall' && part.name === 'task' && typeof part.id === 'string') { - calls.set(part.id, { - instruction: typeof part.arguments?.prompt === 'string' ? part.arguments.prompt : '', - parentResult: calls.get(part.id)?.parentResult ?? false, - }); - } - } - } - if (message?.role === 'toolResult' && message.toolName === 'task' && typeof message.toolCallId === 'string') { - const entry = calls.get(message.toolCallId) ?? { instruction: '', parentResult: false }; - entry.parentResult = true; - calls.set(message.toolCallId, entry); - } - } - parentEvidenceCache.set(rows, calls); - return calls; -} - -export function analyzeExecution(tasks) { - const groups = new Map(); - for (const task of tasks) { - if (!task.parentTurnId) continue; - const key = `${task.ownerId}:${task.parentTurnId}`; - if (!groups.has(key)) groups.set(key, []); - groups.get(key).push(task); - } - const sameTurnGroups = []; - for (const group of groups.values()) { - if (group.length < 2) continue; - const intervals = group.map((task) => [task.startedAt, task.endedAt]); - const complete = intervals.every(([start, end]) => - Number.isFinite(start) && Number.isFinite(end) && start > 0 && end >= start); - if (!complete) { - sameTurnGroups.push({ count: group.length, execution: 'incomplete' }); - continue; - } - intervals.sort((a, b) => a[0] - b[0]); - let latestEnd = 0; - let overlap = false; - for (const [start, end] of intervals) { - if (start < latestEnd) overlap = true; - latestEnd = Math.max(latestEnd, end); - } - sameTurnGroups.push({ - count: group.length, - execution: overlap ? 'overlap' : 'serial', - wallMs: latestEnd - intervals[0][0], - }); - } - return { - sameTurnGroups, - parentResults: tasks.filter((task) => task.parentResult).length, - delivered: tasks.filter((task) => task.deliveredAt).length, - }; -} - -export async function readFlow(rootId, home = dataHome()) { - if (!SESSION_ID.test(rootId)) throw new DataError('INVALID_SESSION', '会话 ID 格式无效。'); - const { root, sessions, tasks } = await withDb(home, (db) => loadTaskTree(db, rootId)); - const parentEvidence = new Map(); - const historyError = new Map(); - for (const id of new Set(tasks.map((task) => task.ownerId))) { - try { parentEvidence.set(id, parentEvidenceByCall(await historyRows(home, sessions.get(id)))); } - catch (error) { historyError.set(id, error instanceof DataError ? error.message : '会话历史暂不可读。'); } - } - const enriched = tasks.map((task) => { - const evidence = parentEvidence.get(task.ownerId)?.get(task.toolCallId) ?? { instruction: '', parentResult: false }; - const parentState = evidence.parentResult ? 'matched' - : historyError.has(task.ownerId) ? 'unreadable' - : task.deliveredAt ? 'unverified' : 'pending'; - return { - ...task, - instruction: evidence.instruction, - parentResult: evidence.parentResult, - parentState, - historyWarning: historyError.get(task.ownerId) || null, - }; - }); - return { - session: { id: root.id, title: root.title || '未命名会话', updatedAt: root.updatedAt }, - observedAt: Date.now(), - tasks: enriched, - analysis: analyzeExecution(enriched), - }; -} - -export async function readTaskDetail(rootId, taskId, home = dataHome()) { - if (!TASK_ID.test(taskId)) throw new DataError('INVALID_TASK', '任务 ID 格式无效。'); - const flow = await readFlow(rootId, home); - const task = flow.tasks.find((item) => item.id === taskId); - if (!task) throw new DataError('TASK_NOT_FOUND', '所选会话中没有这项子任务。'); - if (!task.childId) return { ...task, finalText: '', tools: [] }; - const child = await withDb(home, (db) => db.prepare(`SELECT session_id AS id, - parent_session_id AS parentId, session_kind AS kind, history_relative_dir AS historyDir - FROM local_runtime_sessions WHERE session_id = ?`).get(task.childId)); - if (!child || child.kind !== 'task' || child.parentId !== task.ownerId) { - return { ...task, finalText: '', tools: [], warning: '子会话记录暂不可用或关联不一致' }; - } - try { - const rows = await historyRows(home, child); - const evidence = extractTaskEvidence([], rows, null); - return { ...task, finalText: evidence.finalText, tools: evidence.tools }; - } catch (error) { - return { ...task, finalText: '', tools: [], historyWarning: error instanceof DataError ? error.message : '子会话历史暂不可读。' }; - } -} diff --git a/plugins/huixin615/agent-flow/miniapp/node/server.mjs b/plugins/huixin615/agent-flow/miniapp/node/server.mjs index 6a5d6895..ace767ff 100644 --- a/plugins/huixin615/agent-flow/miniapp/node/server.mjs +++ b/plugins/huixin615/agent-flow/miniapp/node/server.mjs @@ -2,7 +2,6 @@ import { readFile } from 'node:fs/promises'; import { createServer } from 'node:http'; import { join } from 'node:path'; -import { DataError, listConversations, readFlow, readTaskDetail } from './data.mjs'; /** @typedef {import('./miniapp-api.js').MiniAppContext} MiniAppContext */ @@ -15,45 +14,41 @@ function send(response, status, body, type = 'application/json; charset=utf-8') response.end(body); } +const LOOPBACK = new Set(['127.0.0.1', 'localhost', '::1']); +function loopbackName(value) { + return value.replace(/^\[|\]$/g, '').toLowerCase(); +} + +function localRequest(request, port) { + if (!request.headers.host) return false; + try { + const host = new URL(`http://${request.headers.host}`); + if (!LOOPBACK.has(loopbackName(host.hostname)) || Number(host.port) !== port + || host.pathname !== '/' || host.search || host.hash || host.username || host.password) return false; + if (request.headers.origin === undefined) return true; + const origin = new URL(request.headers.origin); + return origin.protocol === 'http:' && origin.origin === host.origin; + } catch { return false; } +} + /** @param {MiniAppContext} context */ export async function start(context) { const client = join(context.pluginRoot, 'miniapp', 'client'); - const [html, css, js] = await Promise.all([ + const [html, css, js, flow] = await Promise.all([ readFile(join(client, 'index.html')), readFile(join(client, 'app.css')), readFile(join(client, 'app.js')), + readFile(join(client, 'flow.mjs')), ]); const server = createServer((request, response) => { - void (async () => { - const url = new URL(request.url ?? '/', 'http://miniapp.local'); - if (request.method !== 'GET') return send(response, 405, JSON.stringify({ error: 'method_not_allowed' })); - if (url.pathname === '/dashboard') return send(response, 200, html, 'text/html; charset=utf-8'); - if (url.pathname === '/app.css') return send(response, 200, css, 'text/css; charset=utf-8'); - if (url.pathname === '/app.js') return send(response, 200, js, 'text/javascript; charset=utf-8'); - try { - if (url.pathname === '/api/sessions') { - return send(response, 200, JSON.stringify({ - ...await listConversations(undefined, Number(url.searchParams.get('limit') || 40), url.searchParams.get('pin')), - selection: 'inferred-most-recent', - })); - } - if (url.pathname === '/api/flow') { - return send(response, 200, JSON.stringify(await readFlow(url.searchParams.get('id') ?? ''))); - } - if (url.pathname === '/api/task') { - return send(response, 200, JSON.stringify(await readTaskDetail( - url.searchParams.get('id') ?? '', url.searchParams.get('task') ?? '', - ))); - } - } catch (error) { - const known = error instanceof DataError; - return send(response, known ? 400 : 500, JSON.stringify({ - error: known ? error.code : 'INTERNAL_ERROR', - message: known ? error.message : '读取本机记录时发生错误。', - })); - } - return send(response, 404, JSON.stringify({ error: 'not_found' })); - })(); + if (!localRequest(request, context.listen.port)) return send(response, 403, JSON.stringify({ error: 'forbidden' })); + if (request.method !== 'GET') return send(response, 405, JSON.stringify({ error: 'method_not_allowed' })); + const url = new URL(request.url ?? '/', 'http://miniapp.local'); + if (url.pathname === '/dashboard') return send(response, 200, html, 'text/html; charset=utf-8'); + if (url.pathname === '/app.css') return send(response, 200, css, 'text/css; charset=utf-8'); + if (url.pathname === '/app.js') return send(response, 200, js, 'text/javascript; charset=utf-8'); + if (url.pathname === '/flow.mjs') return send(response, 200, flow, 'text/javascript; charset=utf-8'); + return send(response, 404, JSON.stringify({ error: 'not_found' })); }); await new Promise((resolve, reject) => { diff --git a/plugins/huixin615/agent-flow/package.json b/plugins/huixin615/agent-flow/package.json index aab6b8dc..3f3d4156 100644 --- a/plugins/huixin615/agent-flow/package.json +++ b/plugins/huixin615/agent-flow/package.json @@ -1,4 +1,5 @@ { + "type": "module", "mcode": { "schemaVersion": 2, "miniApp": "./miniapp/miniapp.json" diff --git a/plugins/huixin615/agent-flow/tests/client.test.mjs b/plugins/huixin615/agent-flow/tests/client.test.mjs new file mode 100644 index 00000000..1b239a7b --- /dev/null +++ b/plugins/huixin615/agent-flow/tests/client.test.mjs @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +class Element { + children = []; + style = {}; + value = ''; + listeners = new Map(); + + set textContent(value) { this.text = String(value); } + get textContent() { return this.text ?? ''; } + + append(...children) { this.children.push(...children); } + replaceChildren(...children) { this.children = children; } + addEventListener(name, listener) { this.listeners.set(name, listener); } +} + +test('imported chats stay unselected until the user chooses one', async () => { + const elements = new Map(); + globalThis.document = { + getElementById(id) { + if (!elements.has(id)) elements.set(id, new Element()); + return elements.get(id); + }, + createElement() { return new Element(); }, + }; + globalThis.window = {}; + await import('../miniapp/client/app.js'); + + const older = 'a'.repeat(64); + const newer = 'b'.repeat(64); + const call = 'c'.repeat(64); + const oldChild = 'd'.repeat(64); + const newChild = 'e'.repeat(64); + const row = (id, event, session, at, extra = {}) => ({ + schema: 'agent-flow-hook/v1', id, event, session, at, + parentSession: null, turn: null, agent: null, toolUse: null, ...extra, + }); + const select = elements.get('session-select'); + const files = elements.get('event-files'); + const importRows = async (rows) => { + const contents = `${rows.map((value) => JSON.stringify(value)).join('\n')}\n`; + files.files = [{ name: 'agent-flow-events.jsonl', size: contents.length, text: async () => contents }]; + await files.listeners.get('change')(); + }; + + await importRows([ + row('old-session', 'SessionStart', older, 1000), + row('old-task', 'PreToolUse', older, 1100, { toolUse: call, taskTitle: 'Old task' }), + row('old-child-start', 'SubagentStart', oldChild, 1200, { agentType: 'explore' }), + row('old-child-stop', 'SubagentStop', oldChild, 1500, { agentType: 'explore' }), + row('new-session', 'SessionStart', newer, 2000), + row('new-task', 'PreToolUse', newer, 2100, { toolUse: call, taskTitle: 'New task' }), + row('new-child-start', 'SubagentStart', newChild, 2200, { agentType: 'explore' }), + row('new-result', 'PostToolUse', newer, 2300, { toolUse: call, childSession: newChild, resultPresent: true }), + row('new-child-stop', 'SubagentStop', newChild, 2500, { agentType: 'explore' }), + ]); + assert.equal(select.value, ''); + assert.equal(elements.get('total').textContent, '0'); + assert.match(elements.get('session-note').textContent, /无法识别当前聊天/); + assert.match(elements.get('file-note').textContent, /已导入 agent-flow-events\.jsonl/); + assert.equal(files.value, ''); + assert.equal(select.children.length, 3); + assert.match(select.children[1].textContent, /\d{4}-\d{2}-\d{2}/); + + elements.get('latest-session').listeners.get('click')(); + assert.equal(select.value, newer); + assert.equal(elements.get('total').textContent, '1'); + assert.equal(elements.get('linked').textContent, '1'); + assert.match(elements.get('session-note').textContent, /推断/); + const visibleText = (element) => [element.textContent, ...element.children.map(visibleText)].join(' '); + assert.match(visibleText(elements.get('children')), new RegExp(newChild.slice(0, 10))); + assert.match(visibleText(elements.get('children')), /New task/); + assert.match(visibleText(elements.get('flow')), new RegExp(newChild.slice(0, 10))); + assert.doesNotMatch(visibleText(elements.get('children')), new RegExp(oldChild.slice(0, 10))); + assert.doesNotMatch(visibleText(elements.get('observation')), /另有|全文件/); + assert.equal(elements.get('observation-panel').hidden, true); + + select.value = older; + select.listeners.get('change')(); + assert.equal(elements.get('total').textContent, '1'); + assert.equal(elements.get('linked').textContent, '0'); + assert.match(elements.get('session-note').textContent, /不一定是当前聊天/); + assert.equal(elements.get('observation-panel').hidden, false); + + await importRows([ + row('new-session', 'SessionStart', newer, 2000), + row('new-task', 'PreToolUse', newer, 2100, { toolUse: call, taskTitle: 'New task' }), + ]); + assert.equal(select.value, ''); + assert.equal(elements.get('total').textContent, '0'); +}); diff --git a/plugins/huixin615/agent-flow/tests/data.test.mjs b/plugins/huixin615/agent-flow/tests/data.test.mjs deleted file mode 100644 index 2b557cdc..00000000 --- a/plugins/huixin615/agent-flow/tests/data.test.mjs +++ /dev/null @@ -1,260 +0,0 @@ -import assert from 'node:assert/strict'; -import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { test } from 'node:test'; -import { DatabaseSync } from 'node:sqlite'; -import { analyzeExecution, extractTaskEvidence, listConversations, readFlow, readTaskDetail } from '../miniapp/node/data.mjs'; - -const parentId = 'mvs_parent'; -const childId = 'mvs_child'; -const parentDir = `2026/10/24/00-00-00-000-session_${Buffer.from(parentId).toString('base64url')}`; -const childDir = `2026/10/24/00-00-01-000-session_${Buffer.from(childId).toString('base64url')}`; - -async function fixture({ badPath = false, childParent = parentId, newerIdle = false } = {}) { - const home = await mkdtemp(join(tmpdir(), 'agent-flow-')); - await mkdir(join(home, 'v2', 'sqlite'), { recursive: true }); - const db = new DatabaseSync(join(home, 'v2', 'sqlite', 'runtime-state.sqlite')); - db.exec(`CREATE TABLE local_runtime_sessions ( - session_id TEXT, parent_session_id TEXT, session_kind TEXT, title TEXT, - status TEXT, created_at_ms INTEGER, updated_at_ms INTEGER, history_relative_dir TEXT); - CREATE TABLE local_runtime_background_tasks ( - kind TEXT, owner_session_id TEXT, created_at_ms INTEGER, record_json TEXT);`); - const insertSession = db.prepare('INSERT INTO local_runtime_sessions VALUES (?,?,?,?,?,?,?,?)'); - insertSession.run(parentId, null, 'conversation', 'Demo parent', 'idle', 100, 200, badPath ? '../outside' : parentDir); - insertSession.run(childId, childParent, 'task', 'Demo child', 'idle', 110, 190, childDir); - if (newerIdle) insertSession.run('mvs_newer', null, 'conversation', 'Newer empty chat', 'idle', 300, 400, parentDir); - const task = { - taskId: 'bg_1', kind: 'subagent', status: 'succeeded', ownerSessionId: parentId, - description: 'Read a file', toolCallId: 'call_1', createdAt: 110, startedAt: 120, - endedAt: 190, deliveredAt: 191, - metadata: { parentSessionId: parentId, parentTurnId: 'turn_1', childSessionId: childId, - agentName: 'explore', executionMode: 'foreground' }, - }; - db.prepare('INSERT INTO local_runtime_background_tasks VALUES (?,?,?,?)').run('subagent', parentId, 110, JSON.stringify(task)); - db.close(); - const writeHistory = async (rel, rows) => { - const dir = join(home, 'v2', 'sessions', rel); - await mkdir(dir, { recursive: true }); - await writeFile(join(dir, 'messages.jsonl'), rows.map((row) => JSON.stringify(row)).join('\n') + '\n'); - }; - if (!badPath) await writeHistory(parentDir, [ - { message: { role: 'assistant', content: [{ type: 'toolCall', name: 'task', id: 'call_1', arguments: { prompt: 'Read README only' } }] } }, - { message: { role: 'toolResult', toolName: 'task', toolCallId: 'call_1', content: [{ type: 'text', text: 'Done: README describes the project.' }] } }, - ]); - await writeHistory(childDir, [ - { message: { role: 'user', content: [{ type: 'text', text: 'host text' }] } }, - { message: { role: 'assistant', content: [{ type: 'toolCall', name: 'read', id: 'read_1', arguments: {} }] } }, - { message: { role: 'assistant', content: [{ type: 'text', text: 'README describes the project.' }] } }, - ]); - return { home, cleanup: () => rm(home, { recursive: true, force: true }) }; -} - -test('links task, child session, parent tool result and child final reply', async () => { - const f = await fixture(); - try { - const { sessions } = await listConversations(f.home); - assert.deepEqual(sessions.map((session) => session.id), [parentId]); - const flow = await readFlow(parentId, f.home); - assert.equal(flow.tasks.length, 1); - const task = flow.tasks[0]; - assert.equal(task.childId, childId); - assert.equal(task.parentResult, true); - assert.equal(task.parentState, 'matched'); - assert.equal(task.instruction, 'Read README only'); - assert.equal(task.finalText, undefined); - const detail = await readTaskDetail(parentId, 'bg_1', f.home); - assert.equal(detail.finalText, 'README describes the project.'); - assert.deepEqual(detail.tools, ['read']); - assert.equal(task.deliveredAt, 191); - } finally { await f.cleanup(); } -}); - -test('older task and conversation remain visible beyond former global caps', async () => { - const f = await fixture(); - try { - const db = new DatabaseSync(join(f.home, 'v2', 'sqlite', 'runtime-state.sqlite')); - const addSession = db.prepare('INSERT INTO local_runtime_sessions VALUES (?,?,?,?,?,?,?,?)'); - const addTask = db.prepare('INSERT INTO local_runtime_background_tasks VALUES (?,?,?,?)'); - db.exec('BEGIN'); - for (let i = 0; i < 1201; i += 1) { - addSession.run(`mvs_idle${i}`, null, 'conversation', 'Idle', 'idle', 1000 + i, 1000 + i, parentDir); - addTask.run('subagent', 'mvs_noise', 1000 + i, JSON.stringify({ - kind: 'subagent', taskId: `bg_noise${i}`, ownerSessionId: 'mvs_noise', - createdAt: 1000 + i, - })); - } - db.exec('COMMIT'); - db.close(); - const firstPage = await listConversations(f.home); - assert.equal(firstPage.sessions[0].id, 'mvs_idle1200'); - assert.equal(firstPage.hasMore, true); - assert.equal((await listConversations(f.home, 1240)).sessions.some(({ id }) => id === parentId), true); - assert.equal((await readFlow(parentId, f.home)).tasks.length, 1); - } finally { await f.cleanup(); } -}); - -test('session picker can request later pages instead of silently hiding task-bearing chats', async () => { - const f = await fixture(); - try { - const db = new DatabaseSync(join(f.home, 'v2', 'sqlite', 'runtime-state.sqlite')); - const addSession = db.prepare('INSERT INTO local_runtime_sessions VALUES (?,?,?,?,?,?,?,?)'); - const addTask = db.prepare('INSERT INTO local_runtime_background_tasks VALUES (?,?,?,?)'); - db.exec('BEGIN'); - for (let i = 0; i < 45; i += 1) { - const id = `mvs_recent${i}`; - addSession.run(id, null, 'conversation', 'Recent', 'idle', 1000 + i, 1000 + i, parentDir); - addTask.run('subagent', id, 1000 + i, JSON.stringify({ - kind: 'subagent', taskId: `bg_recent${i}`, ownerSessionId: id, createdAt: 1000 + i, - })); - } - db.exec('COMMIT'); - db.close(); - const firstPage = await listConversations(f.home); - assert.equal(firstPage.sessions.length, 40); - assert.equal(firstPage.hasMore, true); - assert.equal(firstPage.sessions.some(({ id }) => id === parentId), false); - const pinned = await listConversations(f.home, 40, parentId); - assert.equal(pinned.sessions.at(-1).id, parentId); - const expanded = await listConversations(f.home, 80); - assert.equal(expanded.sessions.some(({ id }) => id === parentId), true); - assert.equal(expanded.hasMore, false); - } finally { await f.cleanup(); } -}); - -test('rejects a valid-looking history path belonging to another session', async () => { - const f = await fixture(); - try { - const db = new DatabaseSync(join(f.home, 'v2', 'sqlite', 'runtime-state.sqlite')); - db.prepare('UPDATE local_runtime_sessions SET history_relative_dir = ? WHERE session_id = ?').run(parentDir, childId); - db.close(); - const detail = await readTaskDetail(parentId, 'bg_1', f.home); - assert.equal(detail.finalText, ''); - assert.match(detail.historyWarning, /会话 ID 不一致/); - } finally { await f.cleanup(); } -}); - -test('rejects a history symlink even when its target stays under the sessions root', async () => { - const f = await fixture(); - try { - const childFile = join(f.home, 'v2', 'sessions', childDir, 'messages.jsonl'); - await rm(childFile); - await symlink(join(f.home, 'v2', 'sessions', parentDir, 'messages.jsonl'), childFile); - const detail = await readTaskDetail(parentId, 'bg_1', f.home); - assert.equal(detail.finalText, ''); - assert.match(detail.historyWarning, /符号链接/); - } finally { await f.cleanup(); } -}); - -test('marks a delivered result unverified when active parent history no longer contains it', async () => { - const f = await fixture(); - try { - await writeFile(join(f.home, 'v2', 'sessions', parentDir, 'messages.jsonl'), - JSON.stringify({ message: { role: 'assistant', content: [{ type: 'text', text: 'Later summary' }] } }) + '\n'); - const task = (await readFlow(parentId, f.home)).tasks[0]; - assert.equal(task.parentResult, false); - assert.equal(task.parentState, 'unverified'); - assert.equal(task.deliveredAt, 191); - } finally { await f.cleanup(); } -}); - -test('links a nested failed background task to its parent task', async () => { - const f = await fixture(); - try { - const grandchildId = 'mvs_grandchild'; - const grandchildDir = `2026/10/24/00-00-02-000-session_${Buffer.from(grandchildId).toString('base64url')}`; - const db = new DatabaseSync(join(f.home, 'v2', 'sqlite', 'runtime-state.sqlite')); - db.prepare('INSERT INTO local_runtime_sessions VALUES (?,?,?,?,?,?,?,?)') - .run(grandchildId, childId, 'task', 'Nested child', 'idle', 125, 185, grandchildDir); - db.prepare('INSERT INTO local_runtime_background_tasks VALUES (?,?,?,?)') - .run('subagent', childId, 125, JSON.stringify({ - kind: 'subagent', taskId: 'bg_nested', ownerSessionId: childId, - status: 'failed', description: 'Read nested file', toolCallId: 'call_nested', - createdAt: 125, startedAt: 130, endedAt: 180, deliveredAt: 181, - metadata: { parentSessionId: childId, parentTurnId: 'turn_nested', - childSessionId: grandchildId, executionMode: 'background', agentName: 'explore' }, - })); - db.close(); - await writeFile(join(f.home, 'v2', 'sessions', childDir, 'messages.jsonl'), [ - { message: { role: 'assistant', content: [{ type: 'toolCall', name: 'task', id: 'call_nested', arguments: { prompt: 'Inspect nested file' } }] } }, - { message: { role: 'toolResult', toolName: 'task', toolCallId: 'call_nested', content: [{ type: 'text', text: 'Task failed' }] } }, - { message: { role: 'assistant', content: [{ type: 'text', text: 'Nested task failed.' }] } }, - ].map((row) => JSON.stringify(row)).join('\n') + '\n'); - await mkdir(join(f.home, 'v2', 'sessions', grandchildDir), { recursive: true }); - await writeFile(join(f.home, 'v2', 'sessions', grandchildDir, 'messages.jsonl'), - JSON.stringify({ message: { role: 'assistant', content: [{ type: 'text', text: 'Could not read file.' }] } }) + '\n'); - const flow = await readFlow(parentId, f.home); - const nested = flow.tasks.find(({ id }) => id === 'bg_nested'); - assert.equal(flow.tasks.length, 2); - assert.equal(nested.depth, 1); - assert.equal(nested.parentTaskId, 'bg_1'); - assert.equal(nested.mode, 'background'); - assert.equal(nested.status, 'failed'); - assert.equal(nested.parentState, 'matched'); - assert.equal((await readTaskDetail(parentId, nested.id, f.home)).finalText, 'Could not read file.'); - } finally { await f.cleanup(); } -}); - -test('defaults to the newest conversation even before it has sub-agent tasks', async () => { - const f = await fixture({ newerIdle: true }); - try { - const { sessions } = await listConversations(f.home); - assert.deepEqual(sessions.map(({ id, taskCount }) => [id, taskCount]), - [['mvs_newer', 0], [parentId, 1]]); - assert.deepEqual((await readFlow('mvs_newer', f.home)).tasks, []); - } finally { await f.cleanup(); } -}); - -test('rejects a child whose parent link contradicts the task record', async () => { - const f = await fixture({ childParent: 'mvs_other' }); - try { - const [selected] = (await readFlow(parentId, f.home)).tasks; - assert.equal(selected.childId, null); - assert.match(selected.warning, /不一致/); - } finally { await f.cleanup(); } -}); - -test('does not attribute one child session to two different task records', async () => { - const f = await fixture(); - try { - const db = new DatabaseSync(join(f.home, 'v2', 'sqlite', 'runtime-state.sqlite')); - db.prepare('INSERT INTO local_runtime_background_tasks VALUES (?,?,?,?)').run('subagent', parentId, 115, - JSON.stringify({ kind: 'subagent', taskId: 'bg_second', ownerSessionId: parentId, - createdAt: 115, metadata: { childSessionId: childId, parentSessionId: parentId } })); - db.close(); - const tasks = (await readFlow(parentId, f.home)).tasks; - assert.equal(tasks[0].childId, childId); - assert.equal(tasks[1].childId, null); - assert.match(tasks[1].warning, /多项任务/); - } finally { await f.cleanup(); } -}); - -test('does not open a history path outside the session directory', async () => { - const f = await fixture({ badPath: true }); - try { - const flow = await readFlow(parentId, f.home); - assert.match(flow.tasks[0].historyWarning, /路径格式/); - assert.equal(flow.tasks[0].parentState, 'unreadable'); - assert.equal(flow.tasks[0].instruction, ''); - } finally { await f.cleanup(); } -}); - -test('does not label injected child text as an instruction or final reply', () => { - const evidence = extractTaskEvidence([], [{ message: { role: 'user', content: [ - { type: 'text', text: 'host text' }, - ] } }], 'call_1'); - assert.equal(evidence.instruction, ''); - assert.equal(evidence.finalText, ''); -}); - -test('uses observed task intervals to distinguish serial and overlapping execution', () => { - const base = { ownerId: parentId, parentTurnId: 'turn_1', deliveredAt: 201, parentResult: true }; - const first = { ...base, startedAt: 100, endedAt: 120 }; - const second = { ...base, startedAt: 120, endedAt: 170 }; - assert.deepEqual(analyzeExecution([first, second]), { - sameTurnGroups: [{ count: 2, execution: 'serial', wallMs: 70 }], - parentResults: 2, delivered: 2, - }); - assert.equal(analyzeExecution([first, { ...second, startedAt: 110 }]).sameTurnGroups[0].execution, 'overlap'); - assert.equal(analyzeExecution([first, { ...second, endedAt: null }]).sameTurnGroups[0].execution, 'incomplete'); -}); diff --git a/plugins/huixin615/agent-flow/tests/events.test.mjs b/plugins/huixin615/agent-flow/tests/events.test.mjs new file mode 100644 index 00000000..b650e05f --- /dev/null +++ b/plugins/huixin615/agent-flow/tests/events.test.mjs @@ -0,0 +1,159 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { normalizeHookEvent } from '../hooks/normalize.mjs'; +import { buildTimeline, executionOverlap, parseEventFiles } from '../miniapp/client/flow.mjs'; + +const parent = 'mvs_parent'; +const child = 'mvs_child'; +// Keep fixture ids opaque and deterministic; the recorder itself checks SHA-256 separately below. +const a = 'a'.repeat(64); +const b = 'b'.repeat(64); +const call = 'c'.repeat(64); +const base = { schema: 'agent-flow-hook/v1', parentSession: null, turn: null, + agent: null, agentType: null, toolUse: null, taskTitle: null, resultPresent: null }; +const row = (id, event, at, extra = {}) => ({ ...base, id, event, at, session: a, ...extra }); +const jsonl = (rows) => rows.map((item) => JSON.stringify(item)).join('\n') + '\n'; + +test('Hook keeps bounded metadata and omits the full task prompt and result', () => { + const normalized = normalizeHookEvent({ + hook_event_name: 'PreToolUse', session_id: parent, turn_id: 'turn_1', + tool_name: 'task', tool_use_id: 'call_1', + tool_input: { description: 'Read README', prompt: 'private full instruction' }, + }, 1000, 'row-1'); + assert.equal(normalized.taskTitle, 'Read README'); + assert.equal(normalized.session.length, 64); + assert.equal(JSON.stringify(normalized).includes('private full instruction'), false); + assert.equal(normalizeHookEvent({ hook_event_name: 'PreToolUse', session_id: parent, tool_name: 'bash' }), null); + const finished = normalizeHookEvent({ + hook_event_name: 'PostToolUse', session_id: parent, tool_name: 'task', + tool_response: { text: 'private child reply', details: { sub_session_id: child } }, + }, 2000, 'row-2'); + assert.equal(finished.resultPresent, true); + assert.equal(finished.childSession.length, 64); + assert.equal(finished.childSession, normalizeHookEvent({ + hook_event_name: 'SubagentStart', session_id: child, agent_id: child, + }, 2001, 'row-3').session); + assert.equal(JSON.stringify(finished).includes('private child reply'), false); + assert.equal(JSON.stringify(finished).includes(child), false); + assert.equal(normalizeHookEvent({ + hook_event_name: 'PostToolUse', session_id: parent, tool_name: 'task', + tool_response: { agentId: child }, + }).childSession, finished.childSession); +}); + +test('a Hook invocation writes one plugin-owned record and no stdout', async () => { + const dir = await mkdtemp(join(tmpdir(), 'agent-flow-events-')); + try { + const script = fileURLToPath(new URL('../hooks/record.mjs', import.meta.url)); + const proc = spawn(process.execPath, [script], { + env: { ...process.env, PLUGIN_DATA: dir }, stdio: ['pipe', 'pipe', 'pipe'], + }); + proc.stdin.end(JSON.stringify({ hook_event_name: 'SubagentStart', session_id: child, + agent_id: child, agent_type: 'explore' })); + let stdout = ''; + proc.stdout.on('data', (chunk) => { stdout += chunk; }); + const code = await new Promise((resolve) => proc.on('close', resolve)); + assert.equal(code, 0); + assert.equal(stdout, ''); + const rows = parseEventFiles([await readFile(join(dir, 'agent-flow-events.jsonl'), 'utf8')]); + assert.equal(rows.length, 1); + assert.equal(rows[0].event, 'SubagentStart'); + } finally { await rm(dir, { recursive: true, force: true }); } +}); + +test('pairs task events by explicit tool ID and leaves child events unlinked without a parent ID', () => { + const rows = parseEventFiles([jsonl([ + row('1', 'SessionStart', 1000), + row('2', 'PreToolUse', 1100, { toolUse: call, taskTitle: 'Read README' }), + row('3', 'SubagentStart', 1200, { session: b, agent: b, agentType: 'explore' }), + row('4', 'SubagentStop', 1500, { session: b, agent: b }), + row('5', 'PostToolUse', 1600, { toolUse: call, resultPresent: true }), + row('5', 'PostToolUse', 1600, { toolUse: call, resultPresent: true }), + ])]); + const flow = buildTimeline(rows); + assert.equal(flow.eventCount, 5); + assert.equal(flow.tasks.length, 1); + assert.equal(flow.tasks[0].endAt, 1600); + assert.equal(flow.tasks[0].resultPresent, true); + assert.equal(flow.children.length, 1); + assert.equal(flow.children[0].parentSession, null); + assert.equal(flow.children[0].endAt, 1500); +}); + +test('an explicit parent ID links a child; missing task starts and incomplete records stay unpaired', () => { + const flow = buildTimeline(parseEventFiles([jsonl([ + row('1', 'SubagentStart', 1000, { session: b, agent: b, parentSession: a }), + row('2', 'PostToolUse', 1100, { toolUse: call }), + row('3', 'PreToolUse', 1200, { toolUse: 'd'.repeat(64) }), + ])])); + assert.equal(flow.children[0].parentSession, a); + assert.equal(flow.orphanResults.length, 1); + assert.equal(flow.tasks.length, 1); + assert.equal(flow.tasks[0].endAt, null); +}); + +test('task result child session links an exact child lifecycle without reading history', () => { + const events = [ + normalizeHookEvent({ hook_event_name: 'PreToolUse', session_id: parent, + tool_name: 'task', tool_use_id: 'task-1', tool_input: { description: 'Read README' } }, 1000, '1'), + normalizeHookEvent({ hook_event_name: 'SubagentStart', session_id: child, + agent_id: child, agent_type: 'explore' }, 1100, '2'), + normalizeHookEvent({ hook_event_name: 'PostToolUse', session_id: parent, + tool_name: 'task', tool_use_id: 'task-1', tool_response: { + details: { sub_session_id: child, final_text: 'private result' }, + } }, 1200, '3'), + normalizeHookEvent({ hook_event_name: 'SubagentStop', session_id: child, + agent_id: child, agent_type: 'explore' }, 3000, '4'), + ]; + const flow = buildTimeline(parseEventFiles([jsonl(events)])); + assert.equal(flow.tasks[0].childSession, flow.children[0].session); + assert.equal(flow.children[0].parentSession, flow.tasks[0].session); + assert.equal(flow.children[0].linkedTaskId, flow.tasks[0].id); + assert.equal(flow.children[0].linkSource, 'task-result'); + assert.equal(JSON.stringify(events).includes('private result'), false); +}); + +test('conflicting structured child IDs never create a guessed relationship', () => { + const flow = buildTimeline(parseEventFiles([jsonl([ + row('1', 'PreToolUse', 1000, { toolUse: call }), + row('2', 'SubagentStart', 1100, { session: b, parentSession: 'd'.repeat(64) }), + row('3', 'PostToolUse', 1200, { toolUse: call, childSession: b }), + ])])); + assert.equal(flow.children[0].relationConflict, true); + assert.equal(flow.children[0].linkedTaskId, null); + assert.equal(flow.children[0].linkSource, null); +}); + +test('rejects unrelated and malformed files instead of inventing a timeline', () => { + assert.throws(() => parseEventFiles(['{"message":"Host history"}\n']), /不是受支持/); + assert.throws(() => parseEventFiles(['{"schema":']), /不是完整的 JSON/); + assert.throws(() => parseEventFiles([jsonl([row('1', 'PreToolUse', 1, { session: '../host' })])]), /不是受支持/); +}); + +test('overlap uses observed task call intervals', () => { + const first = { startAt: 100, endAt: 200 }; + assert.equal(executionOverlap([first, { startAt: 200, endAt: 300 }]), false); + assert.equal(executionOverlap([first, { startAt: 199, endAt: 300 }]), true); + assert.equal(executionOverlap([first, { startAt: 150, endAt: null }]), false); +}); + +test('quick task returns do not erase overlapping child execution', () => { + const flow = buildTimeline(parseEventFiles([jsonl([ + row('1', 'PreToolUse', 1000, { toolUse: call }), + row('2', 'SubagentStart', 1050, { session: b, agent: b }), + row('3', 'PostToolUse', 1100, { toolUse: call, resultPresent: true }), + row('4', 'PreToolUse', 1200, { toolUse: 'd'.repeat(64) }), + row('5', 'SubagentStart', 1250, { session: 'e'.repeat(64), agent: 'e'.repeat(64) }), + row('6', 'PostToolUse', 1300, { toolUse: 'd'.repeat(64), resultPresent: true }), + row('7', 'SubagentStop', 5000, { session: b, agent: b }), + row('8', 'SubagentStop', 6000, { session: 'e'.repeat(64), agent: 'e'.repeat(64) }), + ])])); + assert.equal(executionOverlap(flow.tasks), false); + assert.equal(executionOverlap(flow.children), true); + assert.equal(flow.children.every((item) => item.parentSession === null), true); +});