opencode-session-spawn 2.1.0 → 2.2.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.
- package/README.md +27 -12
- package/index.js +60 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
# opencode-session-spawn
|
|
2
2
|
|
|
3
|
-
opencode
|
|
3
|
+
opencode 插件:**会话生成原语**。做两件事——**创建一个新的顶层会话,并把起始语句注入为该会话首条消息**;以及**子会话反向通知父会话**(`notify_parent`:注入一条 `[relay]` 消息并唤醒父会话)。
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
它的用途是**无人值守长任务编排**:当一条任务超出单会话上下文时,当前会话的模型可以在需要时自行生成一段交接语句、调用工具开出一个新会话继续,新会话拿到语句立即开跑;子会话完成 / 受阻时可用 `notify_parent` 回调 spawn 它的会话。插件不落文书、不读文书、不追踪链、无状态文件——需要交接文书时,把「读取某份文书路径」等引导写进起始语句即可,与其他工具天然兼容。
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## 入口与工具
|
|
8
8
|
|
|
9
|
-
| 入口 | 用法 | 谁触发 |
|
|
9
|
+
| 入口 / 工具 | 用法 | 谁触发 |
|
|
10
10
|
|------|------|--------|
|
|
11
11
|
| 工具 `spawn_session` | `spawn_session({ prompt, title?, agent?, directory? })` | **模型自主调用**(打通无人值守) |
|
|
12
|
+
| 工具 `notify_parent` | `notify_parent({ text })` | **子会话模型自主调用**(完成 / 受阻时回调父会话) |
|
|
12
13
|
| 命令 | `@spawn [--agent <名称>] [--title <标题>] <起始语句>`(兼容 `@relay spawn`) | 人工触发 |
|
|
13
14
|
|
|
14
15
|
参数 / 行为:
|
|
@@ -17,20 +18,26 @@ opencode 插件:**会话生成原语**。只做一件事——**创建一个
|
|
|
17
18
|
- `title`(可选):新会话标题。缺省取 `prompt` 首行(清洗非法字符、截断 60)。
|
|
18
19
|
- `agent`(可选,工具参数):新会话以某 agent/模式启动(如 `leader`)。仅在传了非空白值时透传,交服务端校验。
|
|
19
20
|
- `directory`(可选,工具参数):新会话绑定的项目目录(如 `D:\dev\bbcare`)。仅在传了非空白值时透传,交服务端校验。
|
|
21
|
+
- `parentID`(自动,非参数):`spawn_session` 自动把调用者会话 id 写入新会话 `parentID`(`context.sessionID` 非空白时);命令 `@spawn` 无会话上下文,不带 `parentID`。
|
|
20
22
|
- 命令 flag 说明:`--agent` / `--title` 值均为**单个 token**(不处理引号/空格);仅解析命令后**前导** flag,起始语句内部再出现的 `--xxx` 原样保留;flag 缺值或起始语句为空 → warning toast 且不建会话。`--title` 优先于起始语句首行。
|
|
21
|
-
-
|
|
23
|
+
- 内部调用(spawn):`client.session.create({ body: { title, parentID }, query: { directory } })` → `client.session.promptAsync({ path: { id }, body: { agent, parts: [{ type: "text", text: prompt }] } })`;`agent` / `directory` / `parentID` 未传时不出现对应键。
|
|
24
|
+
- `notify_parent` 行为:仅能通知**父会话**(不提供任意 session id 参数);`client.session.get` 解析自身 `parentID` 后,向父会话注入 `[relay] <text>` 并唤醒它;父会话以服务端默认模式(`build`)被唤醒;空文本 / 无父会话 / 读取或注入失败 → 返回错误文本(不抛);无重试 / 队列 / 追踪(保持无状态原语)。
|
|
22
25
|
- 优先 `promptAsync`(异步 fire-and-forget,**不阻塞父会话**);缺失则回退同步 `prompt`。
|
|
23
26
|
- create 失败 → 不建会话;create 成功但注入失败 → **保留已建会话**并提示 id,不产生重复会话。
|
|
24
27
|
|
|
25
28
|
## 安装(部署到桌面版)
|
|
26
29
|
|
|
27
|
-
|
|
30
|
+
现有部署形态:公共层配置 `share/opencode.json`(挂载到项目内即 `.opencode/opencode.json`)的 `plugin` 数组注册 **npm 包名**:
|
|
28
31
|
|
|
29
|
-
```
|
|
30
|
-
|
|
32
|
+
```jsonc
|
|
33
|
+
{
|
|
34
|
+
"plugin": ["opencode-session-spawn"]
|
|
35
|
+
}
|
|
31
36
|
```
|
|
32
37
|
|
|
33
|
-
|
|
38
|
+
opencode 启动时自动把最新发布安装到包缓存 `~/.cache/opencode/packages/opencode-session-spawn/`(活实例为其中的 `node_modules/opencode-session-spawn/index.js`),**重启桌面版生效**。发布新版本后重启即自动更新;若缓存未刷新,删除 `~/.cache/opencode/packages/opencode-session-spawn@latest` 后重启强制重装。
|
|
39
|
+
|
|
40
|
+
> 旧的本地目录注册写法(如 `./plugins/session-relay/`)已作废;开发与生产验证统一走 `test/test.mjs` + `RELAY_MAIN`(见下节)。
|
|
34
41
|
|
|
35
42
|
## 编排示例
|
|
36
43
|
|
|
@@ -53,19 +60,27 @@ E:\\proj\\handoff\\migration-3.md
|
|
|
53
60
|
@spawn --agent leader --title "[P1-10] sprint" 读取 handoff/migration-3.md 后继续迁移任务
|
|
54
61
|
```
|
|
55
62
|
|
|
63
|
+
父子回复约定(防环路):父会话在起始语句里要求「子会话收尾 / 受阻时调用 `notify_parent`」;子会话执行:
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
notify_parent({ text: "P1-10 已完成,验收报告见 runs/P1-10-acceptance.md" })
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
父会话会收到一条 `[relay] …` 消息并以服务端默认模式被唤醒;收到通知后按需行动,**不要自动回发通知**(`notify_parent` 只向父会话发送,避免环路)。
|
|
70
|
+
|
|
56
71
|
## 开发与测试
|
|
57
72
|
|
|
58
73
|
```bash
|
|
59
74
|
npm install # 安装 devDependency @opencode-ai/plugin(唯一依赖,仅测试用)
|
|
60
|
-
node test/test.mjs #
|
|
75
|
+
node test/test.mjs # 34 用例 / 86 断言
|
|
61
76
|
```
|
|
62
77
|
|
|
63
|
-
- `test/test.mjs` 默认经相对路径 `../index.js` 测项目版;环境变量 `RELAY_MAIN`
|
|
78
|
+
- `test/test.mjs` 默认经相对路径 `../index.js` 测项目版;环境变量 `RELAY_MAIN` 覆盖为其他路径(tarball 解包版 / 包缓存活实例)。`RELAY_MAIN` 目标须能向上解析到 `@opencode-ai/plugin`(解包放项目内即可)。
|
|
64
79
|
- 以尾部 `==== 结果: N 通过, 0 失败 ====` 为准。
|
|
65
80
|
|
|
66
81
|
## 发布
|
|
67
82
|
|
|
68
|
-
|
|
83
|
+
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`。
|
|
69
84
|
|
|
70
85
|
## 历史
|
|
71
86
|
|
package/index.js
CHANGED
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
import { tool } from "@opencode-ai/plugin/tool";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* session-spawn —— 会话生成原语(v2.
|
|
4
|
+
* session-spawn —— 会话生成原语(v2.2.0,2026-09-15 起支持 parentID / notify_parent 反向通知)
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* 只做两件事:
|
|
7
|
+
* 1. 创建一个新的顶层会话,并把起始语句注入为该会话首条消息。
|
|
8
|
+
* create: client.session.create({ body: { title, parentID? }, query: { directory } })
|
|
9
|
+
* inject: client.session.promptAsync({ path: { id }, body: { agent, parts: [{ type: "text", text }] } })
|
|
10
|
+
* (agent / directory / parentID 均可选,仅在显式传入且非空白时透传;不做校验,交服务端)
|
|
11
|
+
* 2. 子会话向父会话注入一条 [relay] 消息并唤醒它(工具 notify_parent;仅父会话,无任意会话注入)。
|
|
10
12
|
*
|
|
11
13
|
* 设计约束(用户定稿):
|
|
12
|
-
* - 插件只负责「建会话 +
|
|
14
|
+
* - 插件只负责「建会话 + 注入语句 + 父会话通知」,不落文书、不读文书、不追踪链、无状态文件。
|
|
13
15
|
* - 编排中若需交接文书,由调用方把「读取文书路径」等引导写进起始语句即可(与其他工具天然兼容)。
|
|
14
16
|
* - 优先 promptAsync(异步 fire-and-forget,不阻塞父会话);缺失则回退同步 prompt。
|
|
15
17
|
* - 两个入口共用同一 spawn():① 工具 spawn_session(模型自主触发,打通无人值守)
|
|
16
18
|
* ② 命令 @spawn / @relay spawn(人工触发)。
|
|
19
|
+
* - spawn_session 自动把调用者会话 id 写入新会话 parentID;notify_parent 只沿 parentID 上行,
|
|
20
|
+
* 不提供任意 session id 参数、无重试 / 队列 / 追踪(保持无状态原语)。
|
|
17
21
|
*
|
|
18
22
|
* 历史:本仓库前身 session-relay 的接力链、两阶段文书、空闲事件驱动、哨兵等机制已全部移除。
|
|
19
23
|
*/
|
|
@@ -87,7 +91,7 @@ export const SessionSpawn = async (ctx) => {
|
|
|
87
91
|
};
|
|
88
92
|
|
|
89
93
|
// 核心:建新会话 + 注入起始语句。
|
|
90
|
-
// opts.agent / opts.directory 可选;仅在非空白时透传(不做校验,交服务端)。
|
|
94
|
+
// opts.agent / opts.directory / opts.parentID 可选;仅在非空白时透传(不做校验,交服务端)。
|
|
91
95
|
// 返回 { id, title, injected, agent?, directory?, error? };create 失败则抛出(未建会话)。
|
|
92
96
|
async function spawn(prompt, title, opts = {}) {
|
|
93
97
|
const text = typeof prompt === "string" ? prompt : "";
|
|
@@ -98,8 +102,10 @@ export const SessionSpawn = async (ctx) => {
|
|
|
98
102
|
const t = (title && String(title).trim()) || defaultTitle(text);
|
|
99
103
|
const agent = nonEmpty(opts.agent);
|
|
100
104
|
const directory = nonEmpty(opts.directory);
|
|
105
|
+
const parentID = nonEmpty(opts.parentID);
|
|
101
106
|
|
|
102
107
|
const createReq = { body: { title: t } };
|
|
108
|
+
if (parentID !== undefined) createReq.body.parentID = parentID;
|
|
103
109
|
if (directory !== undefined) createReq.query = { directory };
|
|
104
110
|
const created = await client.session.create(createReq);
|
|
105
111
|
const id = created && (created.data?.id || created.id);
|
|
@@ -138,9 +144,13 @@ export const SessionSpawn = async (ctx) => {
|
|
|
138
144
|
agent: tool.schema.string().optional().describe("新会话以某 agent/模式启动(如 leader);缺省由服务端默认"),
|
|
139
145
|
directory: tool.schema.string().optional().describe("新会话绑定的项目目录(绝对路径,如 D:\\dev\\bbcare);缺省由服务端默认"),
|
|
140
146
|
},
|
|
141
|
-
async execute(args) {
|
|
147
|
+
async execute(args, context) {
|
|
142
148
|
try {
|
|
143
|
-
const r = await spawn(args.prompt, args.title, {
|
|
149
|
+
const r = await spawn(args.prompt, args.title, {
|
|
150
|
+
agent: args.agent,
|
|
151
|
+
directory: args.directory,
|
|
152
|
+
parentID: context && context.sessionID,
|
|
153
|
+
});
|
|
144
154
|
if (!r.injected) {
|
|
145
155
|
return `会话「${r.title}」(id=${r.id}) 已创建,但起始语句注入失败:${r.error}。请手动打开该会话补发指令。`;
|
|
146
156
|
}
|
|
@@ -150,6 +160,47 @@ export const SessionSpawn = async (ctx) => {
|
|
|
150
160
|
}
|
|
151
161
|
},
|
|
152
162
|
}),
|
|
163
|
+
|
|
164
|
+
// 工具:子会话反向通知父会话(沿 parentID 上行;失败一律返回文本,不抛)。
|
|
165
|
+
notify_parent: tool({
|
|
166
|
+
description:
|
|
167
|
+
"向本会话的父会话(spawn 本会话的会话)注入一条消息并唤醒它:用于子会话完成 / 受阻时通知父会话。仅能通知父会话,不支持指定任意会话;注入文本自动加 [relay] 前缀;父会话以服务端默认模式(build)被唤醒。收到通知后按需行动即可,不要自动回发通知(防环路)。",
|
|
168
|
+
args: {
|
|
169
|
+
text: tool.schema.string().describe("通知内容(注入父会话,自动加 [relay] 前缀)"),
|
|
170
|
+
},
|
|
171
|
+
async execute(args, context) {
|
|
172
|
+
const text = typeof args.text === "string" ? args.text : "";
|
|
173
|
+
if (!text.trim()) return "通知失败:内容为空";
|
|
174
|
+
if (!client || !client.session || typeof client.session.get !== "function") {
|
|
175
|
+
return "通知失败:client.session.get 不可用";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let parentID;
|
|
179
|
+
try {
|
|
180
|
+
const got = await client.session.get({ path: { id: context && context.sessionID } });
|
|
181
|
+
parentID = got && ((got.data && got.data.parentID) || got.parentID);
|
|
182
|
+
} catch (e) {
|
|
183
|
+
return `通知失败:读取会话失败(${e.message})`;
|
|
184
|
+
}
|
|
185
|
+
if (!parentID) return "当前会话没有父会话,无法通知";
|
|
186
|
+
|
|
187
|
+
const body = { parts: [{ type: "text", text: "[relay] " + text }] };
|
|
188
|
+
const injectFn =
|
|
189
|
+
typeof client.session.promptAsync === "function"
|
|
190
|
+
? client.session.promptAsync.bind(client.session)
|
|
191
|
+
: typeof client.session.prompt === "function"
|
|
192
|
+
? client.session.prompt.bind(client.session)
|
|
193
|
+
: null;
|
|
194
|
+
if (!injectFn) return "通知失败:client.session.promptAsync/prompt 均不可用";
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
await injectFn({ path: { id: parentID }, body });
|
|
198
|
+
} catch (e) {
|
|
199
|
+
return `通知失败:注入父会话失败(${e.message})`;
|
|
200
|
+
}
|
|
201
|
+
return `已通知父会话 (id=${parentID})。`;
|
|
202
|
+
},
|
|
203
|
+
}),
|
|
153
204
|
},
|
|
154
205
|
|
|
155
206
|
// 人工命令:@spawn [--agent <名称>] [--title <标题>] <起始语句>(兼容 @relay spawn)。
|