opencode-session-spawn 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +109 -0
  3. package/index.js +407 -0
  4. package/package.json +35 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Qiu Kejian
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,109 @@
1
+ # opencode-session-spawn
2
+
3
+ opencode 插件:**会话生成原语**。做四件事——**创建一个新的顶层会话,并把起始语句注入为该会话首条消息**(新会话保持顶层,会出现在会话列表中,便于人工查看;默认还会让 UI 选中它);**子会话反向通知父会话**(`notify_parent`:注入一条 `[relay]` 消息并唤醒父会话);**向任意指定会话注入并唤醒它**(`notify_session`:父/任意会话向已知会话投递指令/纠偏/叫停);以及**只读列举派生会话**(`list_sessions`:按父链接发现直接 / 传递派生的会话 id)。
4
+
5
+ 它的用途是**无人值守长任务编排**:当一条任务超出单会话上下文时,当前会话的模型可以在需要时自行生成一段交接语句、调用工具开出一个新会话继续,新会话拿到语句立即开跑;子会话完成 / 受阻时可用 `notify_parent` 回调 spawn 它的会话;父会话也可用 `list_sessions` 找回(可能由中间会话间接派生的)会话 id,再用 `notify_session` 向指定会话投递纠偏 / 叫停指令。插件不落文书、不读文书、不追踪链、无状态文件——需要交接文书时,把「读取某份文书路径」等引导写进起始语句即可,与其他工具天然兼容。
6
+
7
+ ## 入口与工具
8
+
9
+ | 入口 / 工具 | 用法 | 谁触发 |
10
+ |------|------|--------|
11
+ | 工具 `spawn_session` | `spawn_session({ prompt, title?, agent?, directory?, model?, select? })` | **模型自主调用**(打通无人值守) |
12
+ | 工具 `notify_parent` | `notify_parent({ text })` | **子会话模型自主调用**(完成 / 受阻时回调父会话) |
13
+ | 工具 `notify_session` | `notify_session({ sessionID, text, prefix?, agent? })` | **父 / 任意会话模型自主调用**(向指定会话投递指令 / 纠偏 / 叫停) |
14
+ | 工具 `list_sessions` | `list_sessions({ parentSessionID?, recursive?, directory? })` | **模型自主调用**(只读发现派生会话 id) |
15
+ | 命令 | `@spawn [--agent <名称>] [--title <标题>] [--no-select] <起始语句>`(兼容 `@relay spawn`) | 人工触发 |
16
+
17
+ 参数 / 行为:
18
+
19
+ - `prompt`(必填):注入新会话的起始语句。可内含读取文件、复述要点、执行下一步等引导。
20
+ - `title`(可选):新会话标题。缺省取 `prompt` 首行(清洗非法字符、截断 60)。
21
+ - `agent`(可选,工具参数):新会话以某 agent/模式启动(如 `leader`)。仅在传了非空白值时透传,交服务端校验。
22
+ - `directory`(可选,工具参数):新会话绑定的项目目录(如 `D:\dev\bbcare`)。仅在传了非空白值时透传,交服务端校验。
23
+ - `model`(可选,工具参数):新会话使用的模型,格式 `provider/model`(如 `deepseek/deepseek-v4-flash`、`opencode/big-pickle`),透传为 `{ providerID, id }`;空 / 无 `/` 视为未传,缺省由服务端默认。
24
+ - `select`(可选,工具参数,默认 `true`):创建后让 UI 选中新会话——优先 `client.tui.selectSession({ sessionID })`,缺失则回退 `client.tui.publish({ type: "tui.session.select", properties: { sessionID } })`;失败 / 不可用仅记录,不影响创建与注入。**TUI 即时生效;桌面端 app 尚未消费该事件(上游 issue #45963),当前为 no-op**,接线后自动打开/切换到该会话 tab。命令 `--no-select` 可关闭。
25
+ - 父链接(自动,非参数):`spawn_session` 把调用者会话 id 写入新会话 **`metadata.spawnParentID`**(`context.sessionID` 非空白时)。**不写 `parentID`**——桌面端会话列表只拉取顶层会话,写 `parentID` 会让新会话变成「子会话」而从列表消失(v2.2.0 的可见性回归);metadata 不影响列表。命令 `@spawn` 无会话上下文,不带父链接。
26
+ - 命令 flag 说明:`--agent` / `--title` 值均为**单个 token**(不处理引号/空格);`--no-select` 为布尔开关;仅解析命令后**前导** flag,起始语句内部再出现的 `--xxx` 原样保留;flag 缺值或起始语句为空 → warning toast 且不建会话。`--title` 优先于起始语句首行。
27
+ - 内部调用(spawn):`client.session.create({ body: { title, metadata?, model? }, query: { directory } })` → `client.tui.selectSession({ sessionID })`(可选)→ `client.session.promptAsync({ path: { id }, body: { agent, parts: [{ type: "text", text: prompt }] } })`;`agent` / `directory` / `model` / `metadata` 未传时不出现对应键。
28
+ - `notify_parent` 行为:仅能通知**父会话**(不提供任意 session id 参数);`client.session.get` 解析自身 `metadata.spawnParentID`(旧会话回退 `parentID`)后,向父会话注入 `[relay] <text>` 并唤醒它;父会话以服务端默认模式(`build`)被唤醒;空文本 / 无父会话 / 读取或注入失败 → 返回错误文本(不抛);无重试 / 队列 / 追踪(保持无状态原语)。
29
+ - `notify_session` 行为:向**指定会话**注入一条消息并唤醒它,与 `notify_parent` 相对(父 / 任意会话 → 指定会话)。先用 `client.session.get({ path: { id } })` **校验目标存在**,再向其注入文本并唤醒;`prefix: false` 关闭 `[relay] ` 前缀(默认加),可选 `agent` 指定唤醒模式。空 `sessionID` / 空 `text` / 目标=自身 / 目标不存在 / 读取或注入失败 / prompt 均不可用 → 返回错误文本(不抛),一律不注入。**默认允许任意 sessionID**(用户明确要求「任意」):可向非派生会话注入,误用可能致互喷 / 环路——请仅对自己已知 / 派生的会话使用,收到通知后按需行动、勿自动回发。
30
+ - `list_sessions` 行为:**只读**列举会话。数据来自 **SDK** `client.session.list({ query: { directory? } })`(**不查 opencode.db**、不落状态文件);按 `metadata.spawnParentID` 过滤(**不用 `session.children`**——它走真 `parentID`,而本插件故意不写 parentID)。`parentSessionID` 缺省 = 当前会话;`recursive: false`(默认)只列**直接**子会话,`true` 沿 `spawnParentID` BFS 列**全部传递后代**(visited 去重防环)。返回紧凑表格文本(每行 `id | title | directory | parent | updated`),无匹配 → `无匹配会话`。旧客户端 `list` 不带 `metadata` 时,逐个 `session.get` 回退读取(N+1,会话量小可接受)。
31
+ - 优先 `promptAsync`(异步 fire-and-forget,**不阻塞父会话**);缺失则回退同步 `prompt`。
32
+ - create 失败 → 不建会话;create 成功但注入失败 → **保留已建会话**并提示 id,不产生重复会话。
33
+
34
+ ## 安装(部署到桌面版)
35
+
36
+ 现有部署形态:公共层配置 `share/opencode.json`(挂载到项目内即 `.opencode/opencode.json`)的 `plugin` 数组注册 **npm 包名**:
37
+
38
+ ```jsonc
39
+ {
40
+ "plugin": ["opencode-session-spawn"]
41
+ }
42
+ ```
43
+
44
+ opencode 启动时自动把最新发布安装到包缓存 `~/.cache/opencode/packages/opencode-session-spawn/`(活实例为其中的 `node_modules/opencode-session-spawn/index.js`),**重启桌面版生效**。发布新版本后重启即自动更新;若缓存未刷新,删除 `~/.cache/opencode/packages/opencode-session-spawn@latest` 后重启强制重装。
45
+
46
+ > 旧的本地目录注册写法(如 `./plugins/session-relay/`)已作废;开发与生产验证统一走 `test/test.mjs` + `RELAY_MAIN`(见下节)。
47
+
48
+ ## 编排示例
49
+
50
+ 模型在长任务中决定交接,直接调用工具(可选 `agent` / `directory`):
51
+
52
+ ```
53
+ spawn_session({
54
+ prompt: `你是本任务的接手会话。先读取交接文书:
55
+ E:\\proj\\handoff\\migration-3.md
56
+ 通读后复述要点(已完成/未完成/下一步第 1 件事),确认后继续执行。`,
57
+ title: "[迁移] 第 3 棒",
58
+ agent: "leader",
59
+ directory: "D:\\dev\\bbcare"
60
+ })
61
+ ```
62
+
63
+ 或人工在会话里发(前导 flag 可省略、顺序不限):
64
+
65
+ ```
66
+ @spawn --agent leader --title "[P1-10] sprint" 读取 handoff/migration-3.md 后继续迁移任务
67
+ ```
68
+
69
+ 父子回复约定(防环路):父会话在起始语句里要求「子会话收尾 / 受阻时调用 `notify_parent`」;子会话执行:
70
+
71
+ ```
72
+ notify_parent({ text: "P1-10 已完成,验收报告见 runs/P1-10-acceptance.md" })
73
+ ```
74
+
75
+ 父会话会收到一条 `[relay] …` 消息并以服务端默认模式被唤醒;收到通知后按需行动,**不要自动回发通知**(`notify_parent` 只向父会话发送,避免环路)。
76
+
77
+ 父会话向指定(可能由中间会话间接派生的)会话投递纠偏 / 叫停:
78
+
79
+ ```
80
+ list_sessions({ recursive: true }) // 找回派生会话 id(默认只列直接子会话;recursive 含全部传递后代)
81
+ notify_session({ sessionID: "ses_xxx", text: "停止当前分支并汇报进度" })
82
+ notify_session({ sessionID: "ses_yyy", text: "仅执行下列修正:…", prefix: false })
83
+ ```
84
+
85
+ `list_sessions` 只读、走 SDK(**不查数据库**),按 `metadata.spawnParentID` 发现会话;`notify_session` 目标须已存在,默认加 `[relay] ` 前缀(`prefix: false` 关闭),亦可带 `agent` 指定唤醒模式。
86
+
87
+ ## 开发与测试
88
+
89
+ ```bash
90
+ npm install # 安装 devDependency @opencode-ai/plugin(唯一依赖,仅测试用)
91
+ node test/test.mjs # 64 用例 / 152 断言
92
+ ```
93
+
94
+ - `test/test.mjs` 默认经相对路径 `../index.js` 测项目版;环境变量 `RELAY_MAIN` 覆盖为其他路径(tarball 解包版 / 包缓存活实例)。`RELAY_MAIN` 目标须能向上解析到 `@opencode-ai/plugin`(解包放项目内即可)。
95
+ - 以尾部 `==== 结果: N 通过, 0 失败 ====` 为准。
96
+
97
+ ## 发布
98
+
99
+ bump 版本号(`package.json` 与 `index.js` 头注释同步)→ `node test/test.mjs` 全绿 → `npm pack` 解包核对哈希 + `RELAY_MAIN` 回归全绿 → commit → push → `npm publish`(不打 git tag;先 `npm whoami` 确认登录态,凭据不入库)→ 重启桌面版实测 `@spawn` / `spawn_session`;实测不过修复补丁重发。详见 `AGENTS.md`。
100
+
101
+ ## 历史
102
+
103
+ 前身 `opencode-session-relay` 的接力链、两阶段交接文书、空闲事件驱动、哨兵兜底、`@relay status/leave/refresh/verify` 等机制已全部移除,仅保留「建会话 + 注入语句」这一核心原语。
104
+
105
+ - **v2.5.0(2026-10-14)**:新增工具 `notify_session`(向任意指定 `sessionID` 注入 `[relay]` 消息并唤醒;`prefix` / `agent` 可选;先校验目标存在;默认允许任意会话)与 `list_sessions`(**只读**、走 SDK `client.session.list`、按 `metadata.spawnParentID` 发现直接 / 传递派生的会话,**不查数据库**)。用于「父 / 任意会话 → 指定会话」的纠偏 / 叫停通道与会话发现(填补 `notify_parent` 只能上行、`task` 操作不了 `spawn_session` 顶层会话的缺口)。
106
+ - **v2.4.0(2026-09-15)**:创建后默认让 UI 选中新会话(`client.tui.selectSession`,回退 `tui.publish` 事件;`select: false` / `@spawn --no-select` 关闭)。TUI 即时生效;桌面端 app 尚未消费 `tui.session.select`(上游 issue #45963),当前为 no-op,接线后自动开 tab。
107
+ - **v2.3.0(2026-09-15)**:父链接由 `parentID` 改为 **`metadata.spawnParentID`**(修复 v2.2.0 可见性回归:写 parentID 会让新会话变成子会话、从桌面端会话列表消失;且子会话默认不能再开 `task` 子代理,会破坏编排);`notify_parent` 兼容旧会话的 `parentID` 回退;新增可选 `model` 参数(`provider/model`)。
108
+ - **v2.2.0**:新增 `notify_parent` 反向通知;`spawn_session` 自动挂 `parentID`。
109
+ - **v2.1.0**:`spawn_session` 支持 `agent` / `directory`;命令 `@spawn` 支持 `--agent` / `--title`。
package/index.js ADDED
@@ -0,0 +1,407 @@
1
+ import { tool } from "@opencode-ai/plugin/tool";
2
+
3
+ /**
4
+ * session-spawn —— 会话生成原语(v2.5.0,2026-10-14 起:新增 notify_session 向指定会话注入 + list_sessions 会话发现)
5
+ *
6
+ * 只做四件事:
7
+ * 1. 创建一个新的顶层会话,并把起始语句注入为该会话首条消息。
8
+ * create: client.session.create({ body: { title, metadata?, model? }, query: { directory } })
9
+ * inject: client.session.promptAsync({ path: { id }, body: { agent, parts: [{ type: "text", text }] } })
10
+ * select: client.tui.selectSession({ sessionID })(可选,默认开;回退 tui.publish 事件)
11
+ * (agent / directory / model 均可选,仅在显式传入且非空白时透传;不做校验,交服务端)
12
+ * 2. 子会话向父会话注入一条 [relay] 消息并唤醒它(工具 notify_parent;仅父会话,上行单向)。
13
+ * 3. 向任意指定会话注入一条 [relay] 消息并唤醒它(工具 notify_session;父/任意会话 → 指定会话,与 notify_parent 相对)。
14
+ * 4. 只读列举会话(工具 list_sessions;按 metadata.spawnParentID 发现直接/传递派生的会话)。
15
+ *
16
+ * 设计约束(用户定稿):
17
+ * - 插件只负责「建会话 + 注入语句 + 通知/发现会话」,不落文书、不读文书、不追踪链、无状态文件。
18
+ * - 编排中若需交接文书,由调用方把「读取文书路径」等引导写进起始语句即可(与其他工具天然兼容)。
19
+ * - 优先 promptAsync(异步 fire-and-forget,不阻塞父会话);缺失则回退同步 prompt。
20
+ * - 入口共用:① 工具 spawn_session / notify_parent / notify_session / list_sessions(模型自主触发)
21
+ * ② 命令 @spawn / @relay spawn(人工触发)。
22
+ * - **父链接写入会话 metadata(`spawnParentID`),不写 `parentID`**:桌面端会话列表只拉取顶层会话,
23
+ * 写 parentID 会让派发会话变成「子会话」而从列表消失(v2.2.0 的可见性回归);metadata 不影响列表,
24
+ * notify_parent 沿 `metadata.spawnParentID` 上行并兼容旧会话的 `parentID` 回退;list_sessions 亦按此过滤。
25
+ * - **创建后默认让 UI 选中新会话**(`tui.selectSession`,失败忽略、不阻塞返回):TUI 即时生效;
26
+ * 桌面端 app 尚未消费该事件(上游 issue #45963),当前为 no-op,接线后自动开 tab。
27
+ * - **list_sessions 只读且走 SDK**(`client.session.list`),不读 opencode.db、不落状态文件(跨平台脆弱 + 保持无状态)。
28
+ * - **notify_session 默认允许任意 sessionID**(用户明确要「任意」);文档标注风险(可向非派生会话注入,误用致环路)。
29
+ *
30
+ * 历史:本仓库前身 session-relay 的接力链、两阶段文书、空闲事件驱动、哨兵等机制已全部移除。
31
+ */
32
+
33
+ // 标题清洗:首行 → 去 Windows 非法字符与控制符 → 截断 60;空则回退 "spawn"。
34
+ function defaultTitle(prompt) {
35
+ return (
36
+ String(prompt || "")
37
+ .split(/\r?\n/)[0]
38
+ .replace(/[<>:"/\\|?*\x00-\x1f]/g, "")
39
+ .trim()
40
+ .slice(0, 60) || "spawn"
41
+ );
42
+ }
43
+
44
+ function userText(parts) {
45
+ if (!Array.isArray(parts)) return "";
46
+ return parts
47
+ .filter((p) => p && p.type === "text" && typeof p.text === "string")
48
+ .map((p) => p.text)
49
+ .join(" ");
50
+ }
51
+
52
+ // 可选参数透传判定:空串 / 纯空白视为未传(返回 undefined)。
53
+ function nonEmpty(v) {
54
+ return typeof v === "string" && v.trim() !== "" ? v : undefined;
55
+ }
56
+
57
+ // 解析 "provider/model" → { providerID, id };空 / 无 "/" / 缺段 → undefined(视为未传)。
58
+ function parseModel(v) {
59
+ const s = nonEmpty(v);
60
+ if (s === undefined) return undefined;
61
+ const i = s.indexOf("/");
62
+ if (i <= 0 || i >= s.length - 1) return undefined;
63
+ return { providerID: s.slice(0, i), id: s.slice(i + 1) };
64
+ }
65
+
66
+ const SPAWN_USAGE = "用法:@spawn [--agent <名称>] [--title <标题>] [--no-select] <起始语句>";
67
+
68
+ // 解析人工命令:@spawn / @relay spawn,支持前导 --agent / --title / --no-select。
69
+ // 返回 null(非 spawn 命令)或 { prompt, agent, title, select } / { error, flag?, agent?, title? }。
70
+ // 规则:仅解析命令后前导的 flag;--agent/--title 值为单个空白分隔 token;--no-select 为布尔开关;剩余内容原样作为起始语句。
71
+ function parseSpawnCommand(text) {
72
+ const m = /^\s*@(?:relay\s+)?spawn\b([\s\S]*)$/i.exec(String(text || ""));
73
+ if (!m) return null;
74
+
75
+ let rest = m[1];
76
+ let agent;
77
+ let title;
78
+ let select = true;
79
+ while (true) {
80
+ const bm = /^\s*--no-select(?=\s|$)/.exec(rest);
81
+ if (bm) {
82
+ rest = rest.slice(bm[0].length);
83
+ select = false;
84
+ continue;
85
+ }
86
+ const fm = /^\s*(--agent|--title)(?=\s|$)/.exec(rest);
87
+ if (!fm) break;
88
+ rest = rest.slice(fm[0].length);
89
+ const vm = /^\s+(\S+)/.exec(rest);
90
+ const value = vm && vm[1];
91
+ if (!value || value.startsWith("--")) {
92
+ return { error: "missing-value", flag: fm[1], agent, title };
93
+ }
94
+ rest = rest.slice(vm[0].length);
95
+ if (fm[1] === "--agent") agent = value;
96
+ else title = value;
97
+ }
98
+
99
+ const prompt = rest.trim();
100
+ if (!prompt) return { error: "empty-prompt", agent, title };
101
+ return { prompt, agent, title, select };
102
+ }
103
+
104
+ export const SessionSpawn = async (ctx) => {
105
+ const { client } = ctx;
106
+
107
+ const notify = async (title, message, variant = "info") => {
108
+ try {
109
+ if (client && client.tui && client.tui.showToast) {
110
+ await client.tui.showToast({ title, message, variant, duration: 15000 });
111
+ }
112
+ } catch (e) {
113
+ console.error(`[session-spawn] toast 通知失败: ${e.message}`);
114
+ }
115
+ };
116
+
117
+ // 创建后让 UI 选中新会话:优先 client.tui.selectSession,缺失则回退 tui.publish 事件;
118
+ // 均不可用 / 失败 → 仅记录,不影响 spawn 结果(桌面端 app 当前未消费该事件,为 no-op)。
119
+ async function selectCreatedSession(sessionID) {
120
+ try {
121
+ if (client && client.tui && typeof client.tui.selectSession === "function") {
122
+ await client.tui.selectSession({ sessionID });
123
+ return;
124
+ }
125
+ if (client && client.tui && typeof client.tui.publish === "function") {
126
+ await client.tui.publish({ type: "tui.session.select", properties: { sessionID } });
127
+ return;
128
+ }
129
+ } catch (e) {
130
+ console.error(`[session-spawn] 选中新会话失败(忽略): ${e.message}`);
131
+ }
132
+ }
133
+
134
+ // 解析注入函数:优先异步 promptAsync(fire-and-forget),缺失回退同步 prompt;均不可用 → null。
135
+ function resolveInjectFn() {
136
+ if (!client || !client.session) return null;
137
+ if (typeof client.session.promptAsync === "function") return client.session.promptAsync.bind(client.session);
138
+ if (typeof client.session.prompt === "function") return client.session.prompt.bind(client.session);
139
+ return null;
140
+ }
141
+
142
+ // 核心:建新会话 + 注入起始语句。
143
+ // opts.agent / opts.directory / opts.model / opts.select / opts.parentSessionID 可选;仅非空白时透传(不做校验)。
144
+ // 父链接写 metadata.spawnParentID(不写 parentID:保持顶层 → 会话列表可见)。
145
+ // 返回 { id, title, injected, agent?, directory?, error? };create 失败则抛出(未建会话)。
146
+ async function spawn(prompt, title, opts = {}) {
147
+ const text = typeof prompt === "string" ? prompt : "";
148
+ if (!text.trim()) throw new Error("起始语句为空");
149
+ if (!client || !client.session || typeof client.session.create !== "function") {
150
+ throw new Error("client.session.create 不可用");
151
+ }
152
+ const t = (title && String(title).trim()) || defaultTitle(text);
153
+ const agent = nonEmpty(opts.agent);
154
+ const directory = nonEmpty(opts.directory);
155
+ const parentSessionID = nonEmpty(opts.parentSessionID);
156
+ const model = parseModel(opts.model);
157
+
158
+ const createReq = { body: { title: t } };
159
+ if (parentSessionID !== undefined) createReq.body.metadata = { spawnParentID: parentSessionID };
160
+ if (model !== undefined) createReq.body.model = model;
161
+ if (directory !== undefined) createReq.query = { directory };
162
+ const created = await client.session.create(createReq);
163
+ const id = created && (created.data?.id || created.id);
164
+ if (!id) throw new Error("create 未返回会话 id");
165
+ if (opts.select !== false) await selectCreatedSession(id);
166
+
167
+ const body = { parts: [{ type: "text", text }] };
168
+ if (agent !== undefined) body.agent = agent;
169
+ const injectFn = resolveInjectFn();
170
+ if (!injectFn) {
171
+ return { id, title: t, injected: false, agent, directory, error: "client.session.promptAsync/prompt 均不可用" };
172
+ }
173
+
174
+ try {
175
+ await injectFn({ path: { id }, body });
176
+ return { id, title: t, injected: true, agent, directory };
177
+ } catch (e) {
178
+ console.error(`[session-spawn] 注入失败(会话 ${id} 已建保留): ${e.message}`);
179
+ return { id, title: t, injected: false, agent, directory, error: e.message };
180
+ }
181
+ }
182
+
183
+ return {
184
+ // 工具:模型自主调用 → 无人值守长任务编排的最短路径。
185
+ tool: {
186
+ spawn_session: tool({
187
+ description:
188
+ "创建一个新的顶层会话并向其注入起始语句,用于无人值守长任务编排:当前会话模型在需要时自行决定把工作交给一个新会话继续。新会话会立即开始执行该起始语句;它保持顶层,会出现在会话列表中(便于人工查看),并默认让 UI 选中它(TUI 即时生效;桌面端需 app 支持,当前为 no-op)。若需让其读取某份交接文书,请把文书路径与读取要求写进 prompt。agent 可指定新会话以某 agent/模式启动(如 leader);directory 可绑定项目目录;model 可指定模型(provider/model)。新会话可用 notify_parent 回通知本会话。",
189
+ args: {
190
+ prompt: tool.schema.string().describe("注入新会话的起始语句(可内含读取交接文书路径等引导)"),
191
+ title: tool.schema.string().optional().describe("新会话标题;缺省取起始语句首行"),
192
+ agent: tool.schema.string().optional().describe("新会话以某 agent/模式启动(如 leader);缺省由服务端默认"),
193
+ directory: tool.schema.string().optional().describe("新会话绑定的项目目录(绝对路径,如 D:\\dev\\bbcare);缺省由服务端默认"),
194
+ model: tool.schema.string().optional().describe("新会话使用的模型(provider/model,如 deepseek/deepseek-v4-flash 或 opencode/big-pickle);缺省由服务端默认"),
195
+ select: tool.schema.boolean().optional().describe("创建后让 UI 选中新会话(默认 true;传 false 关闭)"),
196
+ },
197
+ async execute(args, context) {
198
+ try {
199
+ const r = await spawn(args.prompt, args.title, {
200
+ agent: args.agent,
201
+ directory: args.directory,
202
+ model: args.model,
203
+ select: args.select,
204
+ parentSessionID: context && context.sessionID,
205
+ });
206
+ if (!r.injected) {
207
+ return `会话「${r.title}」(id=${r.id}) 已创建,但起始语句注入失败:${r.error}。请手动打开该会话补发指令。`;
208
+ }
209
+ return `已创建会话「${r.title}」(id=${r.id}) 并注入起始语句,新会话已开始执行。`;
210
+ } catch (e) {
211
+ return `创建会话失败:${e.message}`;
212
+ }
213
+ },
214
+ }),
215
+
216
+ // 工具:子会话反向通知父会话(沿 metadata.spawnParentID 上行,兼容 parentID;失败一律返回文本,不抛)。
217
+ notify_parent: tool({
218
+ description:
219
+ "向本会话的父会话(spawn 本会话的会话)注入一条消息并唤醒它:用于子会话完成 / 受阻时通知父会话。仅能通知父会话,不支持指定任意会话;注入文本自动加 [relay] 前缀;父会话以服务端默认模式(build)被唤醒。收到通知后按需行动即可,不要自动回发通知(防环路)。",
220
+ args: {
221
+ text: tool.schema.string().describe("通知内容(注入父会话,自动加 [relay] 前缀)"),
222
+ },
223
+ async execute(args, context) {
224
+ const text = typeof args.text === "string" ? args.text : "";
225
+ if (!text.trim()) return "通知失败:内容为空";
226
+ if (!client || !client.session || typeof client.session.get !== "function") {
227
+ return "通知失败:client.session.get 不可用";
228
+ }
229
+
230
+ let parentID;
231
+ try {
232
+ const got = await client.session.get({ path: { id: context && context.sessionID } });
233
+ const info = (got && (got.data || got)) || {};
234
+ const meta = info.metadata || {};
235
+ parentID = nonEmpty(meta.spawnParentID) || nonEmpty(info.parentID);
236
+ } catch (e) {
237
+ return `通知失败:读取会话失败(${e.message})`;
238
+ }
239
+ if (!parentID) return "当前会话没有父会话,无法通知";
240
+
241
+ const body = { parts: [{ type: "text", text: "[relay] " + text }] };
242
+ const injectFn = resolveInjectFn();
243
+ if (!injectFn) return "通知失败:client.session.promptAsync/prompt 均不可用";
244
+
245
+ try {
246
+ await injectFn({ path: { id: parentID }, body });
247
+ } catch (e) {
248
+ return `通知失败:注入父会话失败(${e.message})`;
249
+ }
250
+ return `已通知父会话 (id=${parentID})。`;
251
+ },
252
+ }),
253
+
254
+ // 工具:父(或任意)会话向「指定」会话注入消息并唤醒它(与 notify_parent 相对;失败一律返回文本,不抛)。
255
+ notify_session: tool({
256
+ description:
257
+ "向指定会话(sessionID)注入一条消息并唤醒它:用于父会话向自己派生的会话投递指令/纠偏/叫停,或向任意已知会话投递消息。与 notify_parent 相对(后者只通知自己的父会话)。注入文本默认加 [relay] 前缀(prefix:false 关闭)。目标须已存在(会先校验);默认允许任意 sessionID,请仅对自己已知/派生的会话使用,收到通知后按需行动、勿自动回发(防环路)。",
258
+ args: {
259
+ sessionID: tool.schema.string().describe("目标会话 id(如 spawn_session 返回的 id / list_sessions 列出的 id)"),
260
+ text: tool.schema.string().describe("注入内容"),
261
+ prefix: tool.schema.boolean().optional().describe("是否加 [relay] 前缀(默认 true)"),
262
+ agent: tool.schema.string().optional().describe("唤醒模式(如 leader);缺省由服务端默认"),
263
+ },
264
+ async execute(args, context) {
265
+ const sessionID = nonEmpty(args.sessionID);
266
+ const text = typeof args.text === "string" ? args.text : "";
267
+ if (!sessionID) return "通知失败:sessionID 为空";
268
+ if (!text.trim()) return "通知失败:内容为空";
269
+ if (context && context.sessionID && sessionID === context.sessionID) {
270
+ return "通知失败:目标为本会话自身";
271
+ }
272
+ if (!client || !client.session || typeof client.session.get !== "function") {
273
+ return "通知失败:client.session.get 不可用";
274
+ }
275
+ try {
276
+ const got = await client.session.get({ path: { id: sessionID } });
277
+ const info = got && (got.data || got);
278
+ if (!info || !info.id) return `通知失败:目标会话不存在 (id=${sessionID})`;
279
+ } catch (e) {
280
+ return `通知失败:读取目标会话失败(${e.message})`;
281
+ }
282
+
283
+ const body = { parts: [{ type: "text", text: (args.prefix === false ? "" : "[relay] ") + text }] };
284
+ const agent = nonEmpty(args.agent);
285
+ if (agent !== undefined) body.agent = agent;
286
+ const injectFn = resolveInjectFn();
287
+ if (!injectFn) return "通知失败:client.session.promptAsync/prompt 均不可用";
288
+
289
+ try {
290
+ await injectFn({ path: { id: sessionID }, body });
291
+ } catch (e) {
292
+ return `通知失败:注入目标会话失败(${e.message})`;
293
+ }
294
+ return `已通知会话 (id=${sessionID})。`;
295
+ },
296
+ }),
297
+
298
+ // 工具:只读列举会话(默认本会话直接派生的子会话;recursive 含全部传递后代)。
299
+ // 数据来自 SDK client.session.list(不查数据库/文件);按 metadata.spawnParentID 过滤(不用 session.children)。
300
+ list_sessions: tool({
301
+ description:
302
+ "只读列出会话:默认列出本会话直接派生的子会话(按 metadata.spawnParentID),recursive:true 含所有传递后代。数据来自 SDK client.session.list(不查数据库)。用于父会话找回(可能由中间会话派生的)会话 id,再配 notify_session 投递指令。",
303
+ args: {
304
+ parentSessionID: tool.schema.string().optional().describe("父会话 id;缺省=当前会话"),
305
+ recursive: tool.schema.boolean().optional().describe("是否含所有传递后代(默认 false,仅直接子会话)"),
306
+ directory: tool.schema.string().optional().describe("按项目目录过滤(透传 session.list)"),
307
+ },
308
+ async execute(args, context) {
309
+ if (!client || !client.session || typeof client.session.list !== "function") {
310
+ return "列举失败:client.session.list 不可用";
311
+ }
312
+ const root = nonEmpty(args.parentSessionID) || nonEmpty(context && context.sessionID);
313
+ if (!root) return "列举失败:无法确定父会话 id";
314
+
315
+ let all;
316
+ try {
317
+ const res = await client.session.list(args.directory ? { query: { directory: args.directory } } : {});
318
+ all = (res && (res.data || res)) || [];
319
+ } catch (e) {
320
+ return `列举失败:${e.message}`;
321
+ }
322
+ if (!Array.isArray(all)) return "列举失败:client.session.list 返回格式异常";
323
+
324
+ // 读 spawnParentID:优先用 list 返回的 metadata;metadata 整段缺失(旧客户端)则逐个 session.get 回退。
325
+ const canGet = typeof client.session.get === "function";
326
+ async function parentOf(s) {
327
+ if (s && s.metadata) return nonEmpty(s.metadata.spawnParentID);
328
+ if (!canGet) return undefined;
329
+ try {
330
+ const g = await client.session.get({ path: { id: s.id } });
331
+ const info = g && (g.data || g);
332
+ return nonEmpty(info && info.metadata && info.metadata.spawnParentID);
333
+ } catch {
334
+ return undefined;
335
+ }
336
+ }
337
+
338
+ const byParent = new Map();
339
+ for (const s of all) {
340
+ if (!s || !s.id || s.id === root) continue;
341
+ const p = await parentOf(s);
342
+ if (p === undefined) continue;
343
+ if (!byParent.has(p)) byParent.set(p, []);
344
+ byParent.get(p).push(s);
345
+ }
346
+
347
+ const seen = new Set([root]);
348
+ const out = [];
349
+ let frontier = [root];
350
+ while (frontier.length) {
351
+ const next = [];
352
+ for (const id of frontier) {
353
+ for (const s of byParent.get(id) || []) {
354
+ if (seen.has(s.id)) continue;
355
+ seen.add(s.id);
356
+ out.push(s);
357
+ next.push(s.id);
358
+ }
359
+ }
360
+ if (!args.recursive) break;
361
+ frontier = next;
362
+ }
363
+ if (!out.length) return "无匹配会话";
364
+
365
+ return out
366
+ .map((s) => {
367
+ const t = (s.time && s.time.updated) || 0;
368
+ const up = t ? new Date(t).toISOString().slice(0, 19).replace("T", " ") : "";
369
+ return `${s.id} | ${s.title || ""} | ${s.directory || ""} | ${s.metadata?.spawnParentID || s.parentID || ""} | ${up}`;
370
+ })
371
+ .join("\n");
372
+ },
373
+ }),
374
+ },
375
+
376
+ // 人工命令:@spawn [--agent <名称>] [--title <标题>] [--no-select] <起始语句>(兼容 @relay spawn)。
377
+ "chat.message": async ({ sessionID }, { parts }) => {
378
+ try {
379
+ const text = userText(parts);
380
+ const parsed = parseSpawnCommand(text);
381
+ if (!parsed) return;
382
+ if (parsed.error === "missing-value") {
383
+ await notify("spawn", `${parsed.flag} 缺少值。${SPAWN_USAGE}`, "warning");
384
+ return;
385
+ }
386
+ if (parsed.error === "empty-prompt") {
387
+ await notify("spawn", SPAWN_USAGE, "warning");
388
+ return;
389
+ }
390
+ try {
391
+ const r = await spawn(parsed.prompt, parsed.title, { agent: parsed.agent, select: parsed.select });
392
+ if (!r.injected) {
393
+ await notify("spawn", `会话「${r.title}」(id=${r.id}) 已创建,但注入失败:${r.error}`, "warning");
394
+ } else {
395
+ await notify("spawn", `已创建会话「${r.title}」(id=${r.id}) 并注入起始语句。`);
396
+ }
397
+ } catch (e) {
398
+ await notify("spawn", `创建失败:${e.message}`, "error");
399
+ }
400
+ } catch (e) {
401
+ console.error(`[session-spawn] chat.message 处理失败: ${e.message}`);
402
+ }
403
+ },
404
+ };
405
+ };
406
+
407
+ export default SessionSpawn;
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "opencode-session-spawn",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "opencode 会话生成原语:创建新顶层会话并注入起始语句、向指定会话注入消息、只读列举派生会话,用于无人值守长任务编排(工具 spawn_session / notify_parent / notify_session / list_sessions + 命令 @spawn)。",
6
+ "main": "index.js",
7
+ "files": [
8
+ "index.js",
9
+ "package.json",
10
+ "README.md",
11
+ "LICENSE"
12
+ ],
13
+ "keywords": [
14
+ "opencode",
15
+ "plugin",
16
+ "session",
17
+ "spawn",
18
+ "orchestration",
19
+ "unattended"
20
+ ],
21
+ "license": "MIT",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/Qiu-Kejian/opencode-session-spawn.git"
25
+ },
26
+ "engines": {
27
+ "node": ">=18"
28
+ },
29
+ "peerDependencies": {
30
+ "@opencode-ai/plugin": "*"
31
+ },
32
+ "devDependencies": {
33
+ "@opencode-ai/plugin": "^1.1.36"
34
+ }
35
+ }