opencode-session-spawn 2.2.0 → 2.4.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 (3) hide show
  1. package/README.md +15 -8
  2. package/index.js +66 -19
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # opencode-session-spawn
2
2
 
3
- opencode 插件:**会话生成原语**。做两件事——**创建一个新的顶层会话,并把起始语句注入为该会话首条消息**;以及**子会话反向通知父会话**(`notify_parent`:注入一条 `[relay]` 消息并唤醒父会话)。
3
+ opencode 插件:**会话生成原语**。做两件事——**创建一个新的顶层会话,并把起始语句注入为该会话首条消息**(新会话保持顶层,会出现在会话列表中,便于人工查看;默认还会让 UI 选中它);以及**子会话反向通知父会话**(`notify_parent`:注入一条 `[relay]` 消息并唤醒父会话)。
4
4
 
5
5
  它的用途是**无人值守长任务编排**:当一条任务超出单会话上下文时,当前会话的模型可以在需要时自行生成一段交接语句、调用工具开出一个新会话继续,新会话拿到语句立即开跑;子会话完成 / 受阻时可用 `notify_parent` 回调 spawn 它的会话。插件不落文书、不读文书、不追踪链、无状态文件——需要交接文书时,把「读取某份文书路径」等引导写进起始语句即可,与其他工具天然兼容。
6
6
 
@@ -8,9 +8,9 @@ opencode 插件:**会话生成原语**。做两件事——**创建一个新
8
8
 
9
9
  | 入口 / 工具 | 用法 | 谁触发 |
10
10
  |------|------|--------|
11
- | 工具 `spawn_session` | `spawn_session({ prompt, title?, agent?, directory? })` | **模型自主调用**(打通无人值守) |
11
+ | 工具 `spawn_session` | `spawn_session({ prompt, title?, agent?, directory?, model?, select? })` | **模型自主调用**(打通无人值守) |
12
12
  | 工具 `notify_parent` | `notify_parent({ text })` | **子会话模型自主调用**(完成 / 受阻时回调父会话) |
13
- | 命令 | `@spawn [--agent <名称>] [--title <标题>] <起始语句>`(兼容 `@relay spawn`) | 人工触发 |
13
+ | 命令 | `@spawn [--agent <名称>] [--title <标题>] [--no-select] <起始语句>`(兼容 `@relay spawn`) | 人工触发 |
14
14
 
15
15
  参数 / 行为:
16
16
 
@@ -18,10 +18,12 @@ opencode 插件:**会话生成原语**。做两件事——**创建一个新
18
18
  - `title`(可选):新会话标题。缺省取 `prompt` 首行(清洗非法字符、截断 60)。
19
19
  - `agent`(可选,工具参数):新会话以某 agent/模式启动(如 `leader`)。仅在传了非空白值时透传,交服务端校验。
20
20
  - `directory`(可选,工具参数):新会话绑定的项目目录(如 `D:\dev\bbcare`)。仅在传了非空白值时透传,交服务端校验。
21
- - `parentID`(自动,非参数):`spawn_session` 自动把调用者会话 id 写入新会话 `parentID`(`context.sessionID` 非空白时);命令 `@spawn` 无会话上下文,不带 `parentID`。
22
- - 命令 flag 说明:`--agent` / `--title` 值均为**单个 token**(不处理引号/空格);仅解析命令后**前导** flag,起始语句内部再出现的 `--xxx` 原样保留;flag 缺值或起始语句为空 → warning toast 且不建会话。`--title` 优先于起始语句首行。
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`)被唤醒;空文本 / 无父会话 / 读取或注入失败 → 返回错误文本(不抛);无重试 / 队列 / 追踪(保持无状态原语)。
21
+ - `model`(可选,工具参数):新会话使用的模型,格式 `provider/model`(如 `deepseek/deepseek-v4-flash`、`opencode/big-pickle`),透传为 `{ providerID, id }`;空 / 无 `/` 视为未传,缺省由服务端默认。
22
+ - `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` 可关闭。
23
+ - 父链接(自动,非参数):`spawn_session` 把调用者会话 id 写入新会话 **`metadata.spawnParentID`**(`context.sessionID` 非空白时)。**不写 `parentID`**——桌面端会话列表只拉取顶层会话,写 `parentID` 会让新会话变成「子会话」而从列表消失(v2.2.0 的可见性回归);metadata 不影响列表。命令 `@spawn` 无会话上下文,不带父链接。
24
+ - 命令 flag 说明:`--agent` / `--title` 值均为**单个 token**(不处理引号/空格);`--no-select` 为布尔开关;仅解析命令后**前导** flag,起始语句内部再出现的 `--xxx` 原样保留;flag 缺值或起始语句为空 → warning toast 且不建会话。`--title` 优先于起始语句首行。
25
+ - 内部调用(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` 未传时不出现对应键。
26
+ - `notify_parent` 行为:仅能通知**父会话**(不提供任意 session id 参数);`client.session.get` 解析自身 `metadata.spawnParentID`(旧会话回退 `parentID`)后,向父会话注入 `[relay] <text>` 并唤醒它;父会话以服务端默认模式(`build`)被唤醒;空文本 / 无父会话 / 读取或注入失败 → 返回错误文本(不抛);无重试 / 队列 / 追踪(保持无状态原语)。
25
27
  - 优先 `promptAsync`(异步 fire-and-forget,**不阻塞父会话**);缺失则回退同步 `prompt`。
26
28
  - create 失败 → 不建会话;create 成功但注入失败 → **保留已建会话**并提示 id,不产生重复会话。
27
29
 
@@ -72,7 +74,7 @@ notify_parent({ text: "P1-10 已完成,验收报告见 runs/P1-10-acceptance.m
72
74
 
73
75
  ```bash
74
76
  npm install # 安装 devDependency @opencode-ai/plugin(唯一依赖,仅测试用)
75
- node test/test.mjs # 34 用例 / 86 断言
77
+ node test/test.mjs # 46 用例 / 106 断言
76
78
  ```
77
79
 
78
80
  - `test/test.mjs` 默认经相对路径 `../index.js` 测项目版;环境变量 `RELAY_MAIN` 覆盖为其他路径(tarball 解包版 / 包缓存活实例)。`RELAY_MAIN` 目标须能向上解析到 `@opencode-ai/plugin`(解包放项目内即可)。
@@ -85,3 +87,8 @@ bump 版本号(`package.json` 与 `index.js` 头注释同步)→ `node test/
85
87
  ## 历史
86
88
 
87
89
  前身 `opencode-session-relay` 的接力链、两阶段交接文书、空闲事件驱动、哨兵兜底、`@relay status/leave/refresh/verify` 等机制已全部移除,仅保留「建会话 + 注入语句」这一核心原语。
90
+
91
+ - **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。
92
+ - **v2.3.0(2026-09-15)**:父链接由 `parentID` 改为 **`metadata.spawnParentID`**(修复 v2.2.0 可见性回归:写 parentID 会让新会话变成子会话、从桌面端会话列表消失;且子会话默认不能再开 `task` 子代理,会破坏编排);`notify_parent` 兼容旧会话的 `parentID` 回退;新增可选 `model` 参数(`provider/model`)。
93
+ - **v2.2.0**:新增 `notify_parent` 反向通知;`spawn_session` 自动挂 `parentID`。
94
+ - **v2.1.0**:`spawn_session` 支持 `agent` / `directory`;命令 `@spawn` 支持 `--agent` / `--title`。
package/index.js CHANGED
@@ -1,13 +1,14 @@
1
1
  import { tool } from "@opencode-ai/plugin/tool";
2
2
 
3
3
  /**
4
- * session-spawn —— 会话生成原语(v2.2.0,2026-09-15 起支持 parentID / notify_parent 反向通知)
4
+ * session-spawn —— 会话生成原语(v2.4.0,2026-09-15 起:父链接走 metadata + 可选 model + 创建后选中新会话)
5
5
  *
6
6
  * 只做两件事:
7
7
  * 1. 创建一个新的顶层会话,并把起始语句注入为该会话首条消息。
8
- * create: client.session.create({ body: { title, parentID? }, query: { directory } })
8
+ * create: client.session.create({ body: { title, metadata?, model? }, query: { directory } })
9
9
  * inject: client.session.promptAsync({ path: { id }, body: { agent, parts: [{ type: "text", text }] } })
10
- * (agent / directory / parentID 均可选,仅在显式传入且非空白时透传;不做校验,交服务端)
10
+ * select: client.tui.selectSession({ sessionID })(可选,默认开;回退 tui.publish 事件)
11
+ * (agent / directory / model 均可选,仅在显式传入且非空白时透传;不做校验,交服务端)
11
12
  * 2. 子会话向父会话注入一条 [relay] 消息并唤醒它(工具 notify_parent;仅父会话,无任意会话注入)。
12
13
  *
13
14
  * 设计约束(用户定稿):
@@ -16,8 +17,11 @@ import { tool } from "@opencode-ai/plugin/tool";
16
17
  * - 优先 promptAsync(异步 fire-and-forget,不阻塞父会话);缺失则回退同步 prompt。
17
18
  * - 两个入口共用同一 spawn():① 工具 spawn_session(模型自主触发,打通无人值守)
18
19
  * ② 命令 @spawn / @relay spawn(人工触发)。
19
- * - spawn_session 自动把调用者会话 id 写入新会话 parentID;notify_parent 只沿 parentID 上行,
20
- * 不提供任意 session id 参数、无重试 / 队列 / 追踪(保持无状态原语)。
20
+ * - **父链接写入会话 metadata(`spawnParentID`),不写 `parentID`**:桌面端会话列表只拉取顶层会话,
21
+ * 写 parentID 会让派发会话变成「子会话」而从列表消失(v2.2.0 的可见性回归);metadata 不影响列表,
22
+ * notify_parent 沿 `metadata.spawnParentID` 上行,并兼容旧会话的 `parentID` 回退。
23
+ * - **创建后默认让 UI 选中新会话**(`tui.selectSession`,失败忽略、不阻塞返回):TUI 即时生效;
24
+ * 桌面端 app 尚未消费该事件(上游 issue #45963),当前为 no-op,接线后自动开 tab。
21
25
  *
22
26
  * 历史:本仓库前身 session-relay 的接力链、两阶段文书、空闲事件驱动、哨兵等机制已全部移除。
23
27
  */
@@ -46,11 +50,20 @@ function nonEmpty(v) {
46
50
  return typeof v === "string" && v.trim() !== "" ? v : undefined;
47
51
  }
48
52
 
49
- const SPAWN_USAGE = "用法:@spawn [--agent <名称>] [--title <标题>] <起始语句>";
53
+ // 解析 "provider/model" → { providerID, id };空 / 无 "/" / 缺段 → undefined(视为未传)。
54
+ function parseModel(v) {
55
+ const s = nonEmpty(v);
56
+ if (s === undefined) return undefined;
57
+ const i = s.indexOf("/");
58
+ if (i <= 0 || i >= s.length - 1) return undefined;
59
+ return { providerID: s.slice(0, i), id: s.slice(i + 1) };
60
+ }
61
+
62
+ const SPAWN_USAGE = "用法:@spawn [--agent <名称>] [--title <标题>] [--no-select] <起始语句>";
50
63
 
51
- // 解析人工命令:@spawn / @relay spawn,支持前导 --agent / --title。
52
- // 返回 null(非 spawn 命令)或 { prompt, agent, title } / { error, flag?, agent?, title? }。
53
- // 规则:仅解析命令后前导的 flag;值均为单个空白分隔 token;剩余内容原样作为起始语句。
64
+ // 解析人工命令:@spawn / @relay spawn,支持前导 --agent / --title / --no-select。
65
+ // 返回 null(非 spawn 命令)或 { prompt, agent, title, select } / { error, flag?, agent?, title? }。
66
+ // 规则:仅解析命令后前导的 flag;--agent/--title 值为单个空白分隔 token;--no-select 为布尔开关;剩余内容原样作为起始语句。
54
67
  function parseSpawnCommand(text) {
55
68
  const m = /^\s*@(?:relay\s+)?spawn\b([\s\S]*)$/i.exec(String(text || ""));
56
69
  if (!m) return null;
@@ -58,7 +71,14 @@ function parseSpawnCommand(text) {
58
71
  let rest = m[1];
59
72
  let agent;
60
73
  let title;
74
+ let select = true;
61
75
  while (true) {
76
+ const bm = /^\s*--no-select(?=\s|$)/.exec(rest);
77
+ if (bm) {
78
+ rest = rest.slice(bm[0].length);
79
+ select = false;
80
+ continue;
81
+ }
62
82
  const fm = /^\s*(--agent|--title)(?=\s|$)/.exec(rest);
63
83
  if (!fm) break;
64
84
  rest = rest.slice(fm[0].length);
@@ -74,7 +94,7 @@ function parseSpawnCommand(text) {
74
94
 
75
95
  const prompt = rest.trim();
76
96
  if (!prompt) return { error: "empty-prompt", agent, title };
77
- return { prompt, agent, title };
97
+ return { prompt, agent, title, select };
78
98
  }
79
99
 
80
100
  export const SessionSpawn = async (ctx) => {
@@ -90,8 +110,26 @@ export const SessionSpawn = async (ctx) => {
90
110
  }
91
111
  };
92
112
 
113
+ // 创建后让 UI 选中新会话:优先 client.tui.selectSession,缺失则回退 tui.publish 事件;
114
+ // 均不可用 / 失败 → 仅记录,不影响 spawn 结果(桌面端 app 当前未消费该事件,为 no-op)。
115
+ async function selectCreatedSession(sessionID) {
116
+ try {
117
+ if (client && client.tui && typeof client.tui.selectSession === "function") {
118
+ await client.tui.selectSession({ sessionID });
119
+ return;
120
+ }
121
+ if (client && client.tui && typeof client.tui.publish === "function") {
122
+ await client.tui.publish({ type: "tui.session.select", properties: { sessionID } });
123
+ return;
124
+ }
125
+ } catch (e) {
126
+ console.error(`[session-spawn] 选中新会话失败(忽略): ${e.message}`);
127
+ }
128
+ }
129
+
93
130
  // 核心:建新会话 + 注入起始语句。
94
- // opts.agent / opts.directory / opts.parentID 可选;仅在非空白时透传(不做校验,交服务端)。
131
+ // opts.agent / opts.directory / opts.model / opts.select / opts.parentSessionID 可选;仅非空白时透传(不做校验)。
132
+ // 父链接写 metadata.spawnParentID(不写 parentID:保持顶层 → 会话列表可见)。
95
133
  // 返回 { id, title, injected, agent?, directory?, error? };create 失败则抛出(未建会话)。
96
134
  async function spawn(prompt, title, opts = {}) {
97
135
  const text = typeof prompt === "string" ? prompt : "";
@@ -102,14 +140,17 @@ export const SessionSpawn = async (ctx) => {
102
140
  const t = (title && String(title).trim()) || defaultTitle(text);
103
141
  const agent = nonEmpty(opts.agent);
104
142
  const directory = nonEmpty(opts.directory);
105
- const parentID = nonEmpty(opts.parentID);
143
+ const parentSessionID = nonEmpty(opts.parentSessionID);
144
+ const model = parseModel(opts.model);
106
145
 
107
146
  const createReq = { body: { title: t } };
108
- if (parentID !== undefined) createReq.body.parentID = parentID;
147
+ if (parentSessionID !== undefined) createReq.body.metadata = { spawnParentID: parentSessionID };
148
+ if (model !== undefined) createReq.body.model = model;
109
149
  if (directory !== undefined) createReq.query = { directory };
110
150
  const created = await client.session.create(createReq);
111
151
  const id = created && (created.data?.id || created.id);
112
152
  if (!id) throw new Error("create 未返回会话 id");
153
+ if (opts.select !== false) await selectCreatedSession(id);
113
154
 
114
155
  const body = { parts: [{ type: "text", text }] };
115
156
  if (agent !== undefined) body.agent = agent;
@@ -137,19 +178,23 @@ export const SessionSpawn = async (ctx) => {
137
178
  tool: {
138
179
  spawn_session: tool({
139
180
  description:
140
- "创建一个新的顶层会话并向其注入起始语句,用于无人值守长任务编排:当前会话模型在需要时自行决定把工作交给一个新会话继续。新会话会立即开始执行该起始语句。若需让其读取某份交接文书,请把文书路径与读取要求写进 prompt。agent 可指定新会话以某 agent/模式启动(如 leader);directory 可绑定项目目录。",
181
+ "创建一个新的顶层会话并向其注入起始语句,用于无人值守长任务编排:当前会话模型在需要时自行决定把工作交给一个新会话继续。新会话会立即开始执行该起始语句;它保持顶层,会出现在会话列表中(便于人工查看),并默认让 UI 选中它(TUI 即时生效;桌面端需 app 支持,当前为 no-op)。若需让其读取某份交接文书,请把文书路径与读取要求写进 prompt。agent 可指定新会话以某 agent/模式启动(如 leader);directory 可绑定项目目录;model 可指定模型(provider/model)。新会话可用 notify_parent 回通知本会话。",
141
182
  args: {
142
183
  prompt: tool.schema.string().describe("注入新会话的起始语句(可内含读取交接文书路径等引导)"),
143
184
  title: tool.schema.string().optional().describe("新会话标题;缺省取起始语句首行"),
144
185
  agent: tool.schema.string().optional().describe("新会话以某 agent/模式启动(如 leader);缺省由服务端默认"),
145
186
  directory: tool.schema.string().optional().describe("新会话绑定的项目目录(绝对路径,如 D:\\dev\\bbcare);缺省由服务端默认"),
187
+ model: tool.schema.string().optional().describe("新会话使用的模型(provider/model,如 deepseek/deepseek-v4-flash 或 opencode/big-pickle);缺省由服务端默认"),
188
+ select: tool.schema.boolean().optional().describe("创建后让 UI 选中新会话(默认 true;传 false 关闭)"),
146
189
  },
147
190
  async execute(args, context) {
148
191
  try {
149
192
  const r = await spawn(args.prompt, args.title, {
150
193
  agent: args.agent,
151
194
  directory: args.directory,
152
- parentID: context && context.sessionID,
195
+ model: args.model,
196
+ select: args.select,
197
+ parentSessionID: context && context.sessionID,
153
198
  });
154
199
  if (!r.injected) {
155
200
  return `会话「${r.title}」(id=${r.id}) 已创建,但起始语句注入失败:${r.error}。请手动打开该会话补发指令。`;
@@ -161,7 +206,7 @@ export const SessionSpawn = async (ctx) => {
161
206
  },
162
207
  }),
163
208
 
164
- // 工具:子会话反向通知父会话(沿 parentID 上行;失败一律返回文本,不抛)。
209
+ // 工具:子会话反向通知父会话(沿 metadata.spawnParentID 上行,兼容 parentID;失败一律返回文本,不抛)。
165
210
  notify_parent: tool({
166
211
  description:
167
212
  "向本会话的父会话(spawn 本会话的会话)注入一条消息并唤醒它:用于子会话完成 / 受阻时通知父会话。仅能通知父会话,不支持指定任意会话;注入文本自动加 [relay] 前缀;父会话以服务端默认模式(build)被唤醒。收到通知后按需行动即可,不要自动回发通知(防环路)。",
@@ -178,7 +223,9 @@ export const SessionSpawn = async (ctx) => {
178
223
  let parentID;
179
224
  try {
180
225
  const got = await client.session.get({ path: { id: context && context.sessionID } });
181
- parentID = got && ((got.data && got.data.parentID) || got.parentID);
226
+ const info = (got && (got.data || got)) || {};
227
+ const meta = info.metadata || {};
228
+ parentID = nonEmpty(meta.spawnParentID) || nonEmpty(info.parentID);
182
229
  } catch (e) {
183
230
  return `通知失败:读取会话失败(${e.message})`;
184
231
  }
@@ -203,7 +250,7 @@ export const SessionSpawn = async (ctx) => {
203
250
  }),
204
251
  },
205
252
 
206
- // 人工命令:@spawn [--agent <名称>] [--title <标题>] <起始语句>(兼容 @relay spawn)。
253
+ // 人工命令:@spawn [--agent <名称>] [--title <标题>] [--no-select] <起始语句>(兼容 @relay spawn)。
207
254
  "chat.message": async ({ sessionID }, { parts }) => {
208
255
  try {
209
256
  const text = userText(parts);
@@ -218,7 +265,7 @@ export const SessionSpawn = async (ctx) => {
218
265
  return;
219
266
  }
220
267
  try {
221
- const r = await spawn(parsed.prompt, parsed.title, { agent: parsed.agent });
268
+ const r = await spawn(parsed.prompt, parsed.title, { agent: parsed.agent, select: parsed.select });
222
269
  if (!r.injected) {
223
270
  await notify("spawn", `会话「${r.title}」(id=${r.id}) 已创建,但注入失败:${r.error}`, "warning");
224
271
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-session-spawn",
3
- "version": "2.2.0",
3
+ "version": "2.4.0",
4
4
  "type": "module",
5
5
  "description": "opencode 会话生成原语:创建新顶层会话并注入起始语句,用于无人值守长任务编排(工具 spawn_session + 命令 @spawn)。",
6
6
  "main": "index.js",