opencode-session-spawn 2.0.1 → 2.1.1
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 +22 -15
- package/index.js +68 -18
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,59 +8,66 @@ opencode 插件:**会话生成原语**。只做一件事——**创建一个
|
|
|
8
8
|
|
|
9
9
|
| 入口 | 用法 | 谁触发 |
|
|
10
10
|
|------|------|--------|
|
|
11
|
-
| 工具 `spawn_session` | `spawn_session({ prompt, title? })` | **模型自主调用**(打通无人值守) |
|
|
12
|
-
| 命令 | `@spawn <起始语句>`(兼容 `@relay spawn`) | 人工触发 |
|
|
11
|
+
| 工具 `spawn_session` | `spawn_session({ prompt, title?, agent?, directory? })` | **模型自主调用**(打通无人值守) |
|
|
12
|
+
| 命令 | `@spawn [--agent <名称>] [--title <标题>] <起始语句>`(兼容 `@relay spawn`) | 人工触发 |
|
|
13
13
|
|
|
14
14
|
参数 / 行为:
|
|
15
15
|
|
|
16
16
|
- `prompt`(必填):注入新会话的起始语句。可内含读取文件、复述要点、执行下一步等引导。
|
|
17
17
|
- `title`(可选):新会话标题。缺省取 `prompt` 首行(清洗非法字符、截断 60)。
|
|
18
|
-
-
|
|
18
|
+
- `agent`(可选,工具参数):新会话以某 agent/模式启动(如 `leader`)。仅在传了非空白值时透传,交服务端校验。
|
|
19
|
+
- `directory`(可选,工具参数):新会话绑定的项目目录(如 `D:\dev\bbcare`)。仅在传了非空白值时透传,交服务端校验。
|
|
20
|
+
- 命令 flag 说明:`--agent` / `--title` 值均为**单个 token**(不处理引号/空格);仅解析命令后**前导** flag,起始语句内部再出现的 `--xxx` 原样保留;flag 缺值或起始语句为空 → warning toast 且不建会话。`--title` 优先于起始语句首行。
|
|
21
|
+
- 内部调用:`client.session.create({ body: { title }, query: { directory } })` → `client.session.promptAsync({ path: { id }, body: { agent, parts: [{ type: "text", text: prompt }] } })`;`agent` / `directory` 未传时不出现对应键。
|
|
19
22
|
- 优先 `promptAsync`(异步 fire-and-forget,**不阻塞父会话**);缺失则回退同步 `prompt`。
|
|
20
23
|
- create 失败 → 不建会话;create 成功但注入失败 → **保留已建会话**并提示 id,不产生重复会话。
|
|
21
24
|
|
|
22
|
-
##
|
|
25
|
+
## 安装
|
|
23
26
|
|
|
24
|
-
|
|
27
|
+
在全局或项目 `opencode.json(c)` 的 `plugin` 数组注册包名,重启桌面版生效(opencode 自动拉取最新发布并装入包缓存):
|
|
25
28
|
|
|
26
|
-
```
|
|
27
|
-
|
|
29
|
+
```jsonc
|
|
30
|
+
{
|
|
31
|
+
"plugin": ["opencode-session-spawn"]
|
|
32
|
+
}
|
|
28
33
|
```
|
|
29
34
|
|
|
30
|
-
|
|
35
|
+
本地开发也可注册本地目录(opencode 按路径加载):把 `index.js` + `package.json` 复制到自选目录,在 `plugin` 中写该目录相对路径(如 `./plugins/session-spawn`)。
|
|
31
36
|
|
|
32
37
|
## 编排示例
|
|
33
38
|
|
|
34
|
-
|
|
39
|
+
模型在长任务中决定交接,直接调用工具(可选 `agent` / `directory`):
|
|
35
40
|
|
|
36
41
|
```
|
|
37
42
|
spawn_session({
|
|
38
43
|
prompt: `你是本任务的接手会话。先读取交接文书:
|
|
39
44
|
E:\\proj\\handoff\\migration-3.md
|
|
40
45
|
通读后复述要点(已完成/未完成/下一步第 1 件事),确认后继续执行。`,
|
|
41
|
-
title: "[迁移] 第 3 棒"
|
|
46
|
+
title: "[迁移] 第 3 棒",
|
|
47
|
+
agent: "leader",
|
|
48
|
+
directory: "D:\\dev\\bbcare"
|
|
42
49
|
})
|
|
43
50
|
```
|
|
44
51
|
|
|
45
|
-
|
|
52
|
+
或人工在会话里发(前导 flag 可省略、顺序不限):
|
|
46
53
|
|
|
47
54
|
```
|
|
48
|
-
@spawn 读取 handoff/migration-3.md 后继续迁移任务
|
|
55
|
+
@spawn --agent leader --title "[P1-10] sprint" 读取 handoff/migration-3.md 后继续迁移任务
|
|
49
56
|
```
|
|
50
57
|
|
|
51
58
|
## 开发与测试
|
|
52
59
|
|
|
53
60
|
```bash
|
|
54
61
|
npm install # 安装 devDependency @opencode-ai/plugin(唯一依赖,仅测试用)
|
|
55
|
-
node test/test.mjs #
|
|
62
|
+
node test/test.mjs # 23 用例 / 62 断言
|
|
56
63
|
```
|
|
57
64
|
|
|
58
|
-
- `test/test.mjs` 默认经相对路径 `../index.js` 测项目版;环境变量 `RELAY_MAIN`
|
|
65
|
+
- `test/test.mjs` 默认经相对路径 `../index.js` 测项目版;环境变量 `RELAY_MAIN` 覆盖为其他路径(tarball 解包版 / 包缓存活实例)。`RELAY_MAIN` 目标须能向上解析到 `@opencode-ai/plugin`(解包放项目内即可)。
|
|
59
66
|
- 以尾部 `==== 结果: N 通过, 0 失败 ====` 为准。
|
|
60
67
|
|
|
61
68
|
## 发布
|
|
62
69
|
|
|
63
|
-
|
|
70
|
+
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`。
|
|
64
71
|
|
|
65
72
|
## 历史
|
|
66
73
|
|
package/index.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { tool } from "@opencode-ai/plugin/tool";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* session-spawn —— 会话生成原语(v2.
|
|
4
|
+
* session-spawn —— 会话生成原语(v2.1.1,2026-09-12 起支持 agent/directory)
|
|
5
5
|
*
|
|
6
6
|
* 只做一件事:创建一个新的顶层会话,并把起始语句注入为该会话首条消息。
|
|
7
|
-
* create: client.session.create({ body: { title } })
|
|
8
|
-
* inject: client.session.promptAsync({ path: { id }, body: { parts: [{ type: "text", text }] } })
|
|
7
|
+
* create: client.session.create({ body: { title }, query: { directory } })
|
|
8
|
+
* inject: client.session.promptAsync({ path: { id }, body: { agent, parts: [{ type: "text", text }] } })
|
|
9
|
+
* (agent / directory 均可选,仅在显式传入且非空白时透传;不做校验,交服务端)
|
|
9
10
|
*
|
|
10
11
|
* 设计约束(用户定稿):
|
|
11
12
|
* - 插件只负责「建会话 + 注入语句」,不落文书、不读文书、不追踪链、无状态文件。
|
|
@@ -36,6 +37,42 @@ function userText(parts) {
|
|
|
36
37
|
.join(" ");
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
// 可选参数透传判定:空串 / 纯空白视为未传(返回 undefined)。
|
|
41
|
+
function nonEmpty(v) {
|
|
42
|
+
return typeof v === "string" && v.trim() !== "" ? v : undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const SPAWN_USAGE = "用法:@spawn [--agent <名称>] [--title <标题>] <起始语句>";
|
|
46
|
+
|
|
47
|
+
// 解析人工命令:@spawn / @relay spawn,支持前导 --agent / --title。
|
|
48
|
+
// 返回 null(非 spawn 命令)或 { prompt, agent, title } / { error, flag?, agent?, title? }。
|
|
49
|
+
// 规则:仅解析命令后前导的 flag;值均为单个空白分隔 token;剩余内容原样作为起始语句。
|
|
50
|
+
function parseSpawnCommand(text) {
|
|
51
|
+
const m = /^\s*@(?:relay\s+)?spawn\b([\s\S]*)$/i.exec(String(text || ""));
|
|
52
|
+
if (!m) return null;
|
|
53
|
+
|
|
54
|
+
let rest = m[1];
|
|
55
|
+
let agent;
|
|
56
|
+
let title;
|
|
57
|
+
while (true) {
|
|
58
|
+
const fm = /^\s*(--agent|--title)(?=\s|$)/.exec(rest);
|
|
59
|
+
if (!fm) break;
|
|
60
|
+
rest = rest.slice(fm[0].length);
|
|
61
|
+
const vm = /^\s+(\S+)/.exec(rest);
|
|
62
|
+
const value = vm && vm[1];
|
|
63
|
+
if (!value || value.startsWith("--")) {
|
|
64
|
+
return { error: "missing-value", flag: fm[1], agent, title };
|
|
65
|
+
}
|
|
66
|
+
rest = rest.slice(vm[0].length);
|
|
67
|
+
if (fm[1] === "--agent") agent = value;
|
|
68
|
+
else title = value;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const prompt = rest.trim();
|
|
72
|
+
if (!prompt) return { error: "empty-prompt", agent, title };
|
|
73
|
+
return { prompt, agent, title };
|
|
74
|
+
}
|
|
75
|
+
|
|
39
76
|
export const SessionSpawn = async (ctx) => {
|
|
40
77
|
const { client } = ctx;
|
|
41
78
|
|
|
@@ -50,34 +87,42 @@ export const SessionSpawn = async (ctx) => {
|
|
|
50
87
|
};
|
|
51
88
|
|
|
52
89
|
// 核心:建新会话 + 注入起始语句。
|
|
53
|
-
//
|
|
54
|
-
|
|
90
|
+
// opts.agent / opts.directory 可选;仅在非空白时透传(不做校验,交服务端)。
|
|
91
|
+
// 返回 { id, title, injected, agent?, directory?, error? };create 失败则抛出(未建会话)。
|
|
92
|
+
async function spawn(prompt, title, opts = {}) {
|
|
55
93
|
const text = typeof prompt === "string" ? prompt : "";
|
|
56
94
|
if (!text.trim()) throw new Error("起始语句为空");
|
|
57
95
|
if (!client || !client.session || typeof client.session.create !== "function") {
|
|
58
96
|
throw new Error("client.session.create 不可用");
|
|
59
97
|
}
|
|
60
98
|
const t = (title && String(title).trim()) || defaultTitle(text);
|
|
99
|
+
const agent = nonEmpty(opts.agent);
|
|
100
|
+
const directory = nonEmpty(opts.directory);
|
|
61
101
|
|
|
62
|
-
const
|
|
102
|
+
const createReq = { body: { title: t } };
|
|
103
|
+
if (directory !== undefined) createReq.query = { directory };
|
|
104
|
+
const created = await client.session.create(createReq);
|
|
63
105
|
const id = created && (created.data?.id || created.id);
|
|
64
106
|
if (!id) throw new Error("create 未返回会话 id");
|
|
65
107
|
|
|
66
108
|
const body = { parts: [{ type: "text", text }] };
|
|
109
|
+
if (agent !== undefined) body.agent = agent;
|
|
67
110
|
const injectFn =
|
|
68
111
|
typeof client.session.promptAsync === "function"
|
|
69
112
|
? client.session.promptAsync.bind(client.session)
|
|
70
113
|
: typeof client.session.prompt === "function"
|
|
71
114
|
? client.session.prompt.bind(client.session)
|
|
72
115
|
: null;
|
|
73
|
-
if (!injectFn)
|
|
116
|
+
if (!injectFn) {
|
|
117
|
+
return { id, title: t, injected: false, agent, directory, error: "client.session.promptAsync/prompt 均不可用" };
|
|
118
|
+
}
|
|
74
119
|
|
|
75
120
|
try {
|
|
76
121
|
await injectFn({ path: { id }, body });
|
|
77
|
-
return { id, title: t, injected: true };
|
|
122
|
+
return { id, title: t, injected: true, agent, directory };
|
|
78
123
|
} catch (e) {
|
|
79
124
|
console.error(`[session-spawn] 注入失败(会话 ${id} 已建保留): ${e.message}`);
|
|
80
|
-
return { id, title: t, injected: false, error: e.message };
|
|
125
|
+
return { id, title: t, injected: false, agent, directory, error: e.message };
|
|
81
126
|
}
|
|
82
127
|
}
|
|
83
128
|
|
|
@@ -86,14 +131,16 @@ export const SessionSpawn = async (ctx) => {
|
|
|
86
131
|
tool: {
|
|
87
132
|
spawn_session: tool({
|
|
88
133
|
description:
|
|
89
|
-
"创建一个新的顶层会话并向其注入起始语句,用于无人值守长任务编排:当前会话模型在需要时自行决定把工作交给一个新会话继续。新会话会立即开始执行该起始语句。若需让其读取某份交接文书,请把文书路径与读取要求写进 prompt。",
|
|
134
|
+
"创建一个新的顶层会话并向其注入起始语句,用于无人值守长任务编排:当前会话模型在需要时自行决定把工作交给一个新会话继续。新会话会立即开始执行该起始语句。若需让其读取某份交接文书,请把文书路径与读取要求写进 prompt。agent 可指定新会话以某 agent/模式启动(如 leader);directory 可绑定项目目录。",
|
|
90
135
|
args: {
|
|
91
136
|
prompt: tool.schema.string().describe("注入新会话的起始语句(可内含读取交接文书路径等引导)"),
|
|
92
137
|
title: tool.schema.string().optional().describe("新会话标题;缺省取起始语句首行"),
|
|
138
|
+
agent: tool.schema.string().optional().describe("新会话以某 agent/模式启动(如 leader);缺省由服务端默认"),
|
|
139
|
+
directory: tool.schema.string().optional().describe("新会话绑定的项目目录(绝对路径,如 D:\\dev\\bbcare);缺省由服务端默认"),
|
|
93
140
|
},
|
|
94
141
|
async execute(args) {
|
|
95
142
|
try {
|
|
96
|
-
const r = await spawn(args.prompt, args.title);
|
|
143
|
+
const r = await spawn(args.prompt, args.title, { agent: args.agent, directory: args.directory });
|
|
97
144
|
if (!r.injected) {
|
|
98
145
|
return `会话「${r.title}」(id=${r.id}) 已创建,但起始语句注入失败:${r.error}。请手动打开该会话补发指令。`;
|
|
99
146
|
}
|
|
@@ -105,19 +152,22 @@ export const SessionSpawn = async (ctx) => {
|
|
|
105
152
|
}),
|
|
106
153
|
},
|
|
107
154
|
|
|
108
|
-
// 人工命令:@spawn <起始语句>(兼容 @relay spawn)。
|
|
155
|
+
// 人工命令:@spawn [--agent <名称>] [--title <标题>] <起始语句>(兼容 @relay spawn)。
|
|
109
156
|
"chat.message": async ({ sessionID }, { parts }) => {
|
|
110
157
|
try {
|
|
111
158
|
const text = userText(parts);
|
|
112
|
-
const
|
|
113
|
-
if (!
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
159
|
+
const parsed = parseSpawnCommand(text);
|
|
160
|
+
if (!parsed) return;
|
|
161
|
+
if (parsed.error === "missing-value") {
|
|
162
|
+
await notify("spawn", `${parsed.flag} 缺少值。${SPAWN_USAGE}`, "warning");
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (parsed.error === "empty-prompt") {
|
|
166
|
+
await notify("spawn", SPAWN_USAGE, "warning");
|
|
117
167
|
return;
|
|
118
168
|
}
|
|
119
169
|
try {
|
|
120
|
-
const r = await spawn(prompt);
|
|
170
|
+
const r = await spawn(parsed.prompt, parsed.title, { agent: parsed.agent });
|
|
121
171
|
if (!r.injected) {
|
|
122
172
|
await notify("spawn", `会话「${r.title}」(id=${r.id}) 已创建,但注入失败:${r.error}`, "warning");
|
|
123
173
|
} else {
|