dsh-interconnect 0.5.0 → 0.6.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 CHANGED
@@ -7,17 +7,19 @@
7
7
 
8
8
  **`interconnect`** —— host 服务(`ctx.interconnect`):
9
9
 
10
- - `send` / `ping` HTTP 端点(`/interconnect/*`):跨实例、跨机器投递消息、探测活性
10
+ - `send` / `list` / `ping` HTTP 端点(`/interconnect/*`):跨实例、跨机器投递消息、枚举 live session、探测活性
11
11
  - `/interconnect/link` WebSocket 端点:双向实时事件推流,含心跳与指数退避重连
12
12
  - 事件 fan-out(HTTP + WebSocket),入站事件以 `interconnect/event` 发出
13
13
  - 共享密钥鉴权(`DSH_INTERCONNECT_TOKEN`,bearer,fail-closed,timing-safe 比较)
14
14
 
15
15
  **`tool-interconnect`** —— 模型可见工具:
16
16
 
17
- - `interconnect_send`:向对端实例的指定 session 投递消息,可选 `delivery` 指定投递模式
17
+ - `interconnect_send`:向对端实例的指定 session 投递消息;可选 `delivery` 选投递模式、`resume` 唤醒离线 session
18
18
  - `interconnect_list`:列出对端实例的 live session(id + 标题 + 状态),用于在不预先知道 session id 时寻址
19
19
  - `interconnect_ping`:探测对端实例活性与身份
20
20
 
21
+ ## 用法
22
+
21
23
  ### 寻址
22
24
 
23
25
  `interconnect_send` 需要一个 session id,而调用方通常并不知道。`interconnect_list` 返回对端
@@ -35,22 +37,59 @@ session-b07326da-… [running]
35
37
  只列 live session 是有意的:`send` 能到达的正好是这些。对端存在但没有运行 agent 的 session
36
38
  不会出现在列表里,也收不到消息。
37
39
 
38
- ### 投递失败的两种原因
40
+ ### 投递失败的原因
39
41
 
40
- `delivered: false` 单独一个布尔值无法据以行动,因为两种失败需要相反的应对,所以
42
+ `delivered: false` 单独一个布尔值无法据以行动,因为不同失败需要不同应对,所以
41
43
  `SendResult.reason` 会指明是哪一种:
42
44
 
43
45
  | `reason` | 含义 | 应对 |
44
46
  |---|---|---|
45
- | `session-not-live` | 对端**答复了**,但那个 session 没有运行中的 agent | 重试同一个 id 无用;用 `interconnect_list` 换一个目标 |
47
+ | `session-not-live` | 对端**答复了**,但那个 session 没有运行中的 agent | 重试同一个 id 无用;用 `interconnect_list` 换目标,或带 `resume` |
46
48
  | `unreachable` | 没拿到可用答复(传输失败,或鉴权被拒) | 目标 session 可能完好,重试可能成功 |
49
+ | `resume-refused` | 请求了唤醒,但对端不允许(`allowResume: false`) | 再带 `resume` 也没用 |
50
+ | `resume-failed` | 允许唤醒且尝试了,但没得到 live agent(无此持久化 session,或被别的 owner 持有) | 换目标 |
47
51
 
48
52
  `reason` 恰好在 `delivered` 为 false 时出现。
49
53
 
50
- **不会自动 resume 已持久化但未打开的 session**,这是有意的:`agents.resume()` 返回的
51
- handle 由调用方 context 拥有,实测确认插件 fiber 被 dispose 时会把 resume 出来的 agent 和
52
- session 一起拆掉(用根 ctx 调用则不会——ownership 跟随调用时用的那个 context)。让本插件的
53
- 卸载连带杀掉用户正在用的会话是不可接受的,所以这里只报告 `session-not-live`。
54
+ ### 唤醒离线 session(`resume`,默认关)
55
+
56
+ `SendPayload.resume: true` 让对端唤醒一个已持久化但没有运行 agent 的 session。
57
+
58
+ **默认关闭是有意的。** 实测确认:消息投递到 session 后会触发一次**完整的 agent 回合**——
59
+ `wakeDriver()` → `kick()` → `turn()` → `llm.stream()`,即一次计费的模型调用,且 assembly
60
+ 里带着该 session 的完整工具集。在一个用户没打开、看不到、也无法中断的会话里启动这些,和
61
+ 「推一下已经开着的会话」不是一个量级,所以必须由发送方显式请求。
62
+
63
+ 两侧都有控制权:
64
+
65
+ - **发送方**按消息决定 `resume`(默认不唤醒)
66
+ - **接收方**用 `Config.allowResume`(默认 `true`)一票否决——因为花钱和跑工具的是它那台机器;
67
+ 拒绝时在跑 lookup 之前就短路,回 `resume-refused`
68
+
69
+ 唤醒**不是**调本插件的 `ctx.agents.resume()`,而是走 Host 已配置的 `agent` lookup
70
+ (`typert.lookups.get('agent')`)。这一点是关键:`resume()` 返回的 handle 由**调用方
71
+ context** 拥有,实测确认插件 fiber 被 dispose 时会把 resume 出来的 agent 和 session 一起
72
+ 拆掉(同一调用改用根 ctx 则两者都存活)。交给 Host 的 resolver 之后 owner 是 api-proxy,
73
+ 而且它会按 session 日志里记录的 preset 重建工具集——不是空壳。
74
+
75
+ 没有 Host lookup 的部署(headless、无 api-proxy 的 profile)会降级为 `session-not-live`,
76
+ 不会报错。
77
+
78
+ 唤醒**只把消息放进 inbox**,是否真的开始处理取决于 `delivery`:
79
+
80
+ ```text
81
+ # 唤醒并让对方实际处理(会起一个计费回合)
82
+ interconnect_send(baseUrl, sessionId, text, resume=true, delivery="followup")
83
+
84
+ # 唤醒但不起回合:只写入上下文,等对方下次被唤醒时一起读
85
+ interconnect_send(baseUrl, sessionId, text, resume=true, delivery="inject")
86
+ ```
87
+
88
+ 已实测:`resume=true` + `inject` 之后目标从非 live 变 live(`interconnect_list` 计数 +1),
89
+ 且该 session 日志里只多一条 `agent/inbox/spliced`、**后面没有 `turn/start`**。
90
+
91
+ **已知限制**:磁盘格式过旧的 session 无法唤醒,返回 `resume-failed`。这不是本插件的限制——
92
+ Host 自己的 resume 路径对同一个 session 报 `SessionFormatUnsupportedError`,同样失败。
54
93
 
55
94
  ### 投递模式
56
95
 
@@ -66,6 +105,30 @@ session 一起拆掉(用根 ctx 调用则不会——ownership 跟随调用时
66
105
  该字段时沿用接收方 `Config.delivery` 的配置。`SendResult.delivery` 回报实际生效的
67
106
  模式,发送方据此判断覆盖是否被采纳。
68
107
 
108
+ ## 配置
109
+
110
+ `interconnect` 行的 `config`(全部可选,下表为默认值):
111
+
112
+ | 字段 | 默认 | 说明 |
113
+ |---|---|---|
114
+ | `instanceId` | `'dsh'` | 本实例自报的 id,出现在 `ping`/`send`/`list` 的回包里。仅诊断用,**不作为路由依据** |
115
+ | `requestTimeoutMs` | `10000` | 出站请求超时,上限 60000 |
116
+ | `peers` | `[]` | 启动时的事件 fan-out 目标;运行时可用 `subscribe()` 增补 |
117
+ | `delivery` | `'followup'` | 入站消息未带 `delivery` 时的默认投递模式 |
118
+ | `allowResume` | `true` | 是否允许发送方用 `resume` 唤醒本机的离线 session |
119
+
120
+ ```yaml
121
+ - id: interconnect
122
+ config:
123
+ instanceId: my-box
124
+ peers: ['http://peer-host:3080']
125
+ delivery: followup
126
+ allowResume: false # 拒绝一切唤醒请求
127
+ ```
128
+
129
+ 鉴权用的共享密钥不在这里,而是取自 credentials 的 `DSH_INTERCONNECT_TOKEN`(fail-closed:
130
+ 没有 token 时端点返回 403)。
131
+
69
132
  ## 安装
70
133
 
71
134
  本包已发布到 npm:[`dsh-interconnect`](https://www.npmjs.com/package/dsh-interconnect)。
@@ -109,16 +172,20 @@ pnpm run build # esbuild → lib/
109
172
 
110
173
  ## 验证
111
174
 
112
- - 22/22 单测通过(服务 17 + 工具 5);类型检查、构建均干净。
175
+ - 50/50 单测通过(服务 + 工具);类型检查、构建均干净。
113
176
  - 已在两台机器之间实测双向互通:消息投递、WebSocket 事件推流、以及 agent 经
114
177
  `interconnect_send` 工具反向回发,均验证通过。
115
178
  - CI(GitHub Actions):clone 公开 DSH 仓库作为 sibling,跑 `pnpm run check`。
116
179
  - 已发布版本:从 registry 下载的 tarball 与本地构建 shasum 一致;干净消费端
117
180
  解析 `.`、`./tool-interconnect` 两个入口的类型均通过,负例(把 `string` 赋给
118
181
  `number`)如期报 `TS2322`。
119
- - 投递消息的 `source` 为 `{ kind: 'plugin', plugin: 'dsh-interconnect' }`,不是
120
- `{ kind: 'user' }`——接收方 agent 据此区分跨实例投递与本地用户输入。负例:把
121
- 该 source 改回 `kind: 'user'`,对应断言转红。
182
+ - 投递消息以 `source: { kind: 'plugin', plugin: 'dsh-interconnect' }` 落库,而不是
183
+ `{ kind: 'user' }`。负例:把该 source 改回 `kind: 'user'`,对应断言转红。
184
+ **注意 `source` 不进模型上下文**——只有 `role` 和 `content` 会,而 `role` 是 `user`。
185
+ 所以这个字段的价值在持久化日志与 UI 归因,接收方的**模型本身分辨不出**消息来自插件。
186
+ - 每个行为改动都配负例对照(删掉实现使对应断言转红),而不只是「测试通过」。
187
+ 例如:去掉请求处理器的 `await` 会让 25 个测试转红;去掉 `resume` 的发送方 opt-in
188
+ 门或接收方否决门,各让 1 个转红。
122
189
 
123
190
  ## 许可
124
191
 
package/lib/index.js CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  } from "@deepseek-ai/dsh-host-apiproxy/api";
8
8
  import { credentialRef } from "@deepseek-ai/dsh-credentials";
9
9
  import { boundContextSummary, createUserMessage } from "@deepseek-ai/dsh-llm";
10
+ import { hasApiRemoteSubagentOwner } from "@deepseek-ai/dsh-api-remotes";
10
11
 
11
12
  // ../.dsh/source/staging-20260807T143236Z/vendor/cosmokit/src/misc.ts
12
13
  function isNullable(value) {
@@ -848,7 +849,8 @@ var linkFrameSchema = Schema.union([
848
849
  var sendPayloadSchema = Schema.object({
849
850
  sessionId: Schema.string(),
850
851
  text: Schema.string(),
851
- delivery: Schema.union([Schema.const("followup"), Schema.const("steer"), Schema.const("inject")])
852
+ delivery: Schema.union([Schema.const("followup"), Schema.const("steer"), Schema.const("inject")]),
853
+ resume: Schema.boolean()
852
854
  });
853
855
  var eventPayloadSchema = Schema.object({
854
856
  sender: Schema.string(),
@@ -860,11 +862,13 @@ var InterconnectService = class extends Service {
860
862
  instanceId: Schema.string().default("dsh"),
861
863
  requestTimeoutMs: Schema.natural().max(6e4).default(1e4),
862
864
  peers: Schema.array(Schema.string()).default([]),
863
- delivery: Schema.union([Schema.const("followup"), Schema.const("steer"), Schema.const("inject")]).default("followup")
865
+ delivery: Schema.union([Schema.const("followup"), Schema.const("steer"), Schema.const("inject")]).default("followup"),
866
+ allowResume: Schema.boolean().default(true)
864
867
  });
865
868
  instanceId;
866
869
  requestTimeoutMs;
867
870
  delivery;
871
+ allowResume;
868
872
  peers = /* @__PURE__ */ new Set();
869
873
  subscriptions = [];
870
874
  server = new WebSocketServer({ noServer: true });
@@ -878,6 +882,7 @@ var InterconnectService = class extends Service {
878
882
  this.instanceId = config.instanceId;
879
883
  this.requestTimeoutMs = config.requestTimeoutMs;
880
884
  this.delivery = config.delivery ?? "followup";
885
+ this.allowResume = config.allowResume ?? true;
881
886
  for (const peer of config.peers ?? []) this.peers.add(trimBase(peer));
882
887
  const route = {
883
888
  kind: "prefix",
@@ -949,7 +954,8 @@ var InterconnectService = class extends Service {
949
954
  const payload = {
950
955
  sessionId: request.sessionId,
951
956
  text: request.text,
952
- ...request.delivery === void 0 ? {} : { delivery: request.delivery }
957
+ ...request.delivery === void 0 ? {} : { delivery: request.delivery },
958
+ ...request.resume === void 0 ? {} : { resume: request.resume }
953
959
  };
954
960
  const result = await this.post(request.baseUrl, "send", payload);
955
961
  return result ?? { delivered: false, instance: this.instanceId, reason: "unreachable" };
@@ -1088,7 +1094,7 @@ var InterconnectService = class extends Service {
1088
1094
  return;
1089
1095
  }
1090
1096
  try {
1091
- const value = this.runEndpoint(endpoint, parsed.data.payload);
1097
+ const value = await this.runEndpoint(endpoint, parsed.data.payload);
1092
1098
  writeFull(res, rpcId, { ok: true, value });
1093
1099
  } catch (error) {
1094
1100
  if (error instanceof UnknownEndpointError) {
@@ -1113,7 +1119,7 @@ var InterconnectService = class extends Service {
1113
1119
  }
1114
1120
  }
1115
1121
  /** Dispatch one authenticated business call. */
1116
- runEndpoint(endpoint, payload) {
1122
+ async runEndpoint(endpoint, payload) {
1117
1123
  if (endpoint === "ping") {
1118
1124
  return { pong: true, instance: this.instanceId };
1119
1125
  }
@@ -1157,7 +1163,8 @@ var InterconnectService = class extends Service {
1157
1163
  * matches how the Host's own session listing treats its projection column.
1158
1164
  */
1159
1165
  listSessions() {
1160
- const sessions = this.ctx.agents.list().map((agent) => {
1166
+ const reachable = this.ctx.agents.list().filter((agent) => !hasApiRemoteSubagentOwner(this.ctx, agent.session, agent));
1167
+ const sessions = reachable.map((agent) => {
1161
1168
  let title;
1162
1169
  try {
1163
1170
  const snapshot = this.ctx.get("sessionProjections")?.snapshot(agent.session);
@@ -1174,11 +1181,48 @@ var InterconnectService = class extends Service {
1174
1181
  });
1175
1182
  return { sessions, instance: this.instanceId };
1176
1183
  }
1177
- /** Deliver one message to a live local session, if it exists. */
1178
- deliver(payload) {
1179
- const agent = this.ctx.agents.get(payload.sessionId);
1184
+ /**
1185
+ * Resolve a session that is not currently live, when the sender asked to wake
1186
+ * it. Returns the agent, or the reason it stays undelivered.
1187
+ *
1188
+ * The resume is delegated to the Host's configured `agent` lookup rather than
1189
+ * calling `ctx.agents.resume()` here, and that is the whole point: a handle
1190
+ * from `resume()` is owned by the CALLING context, so resuming on this
1191
+ * plugin's fiber would tear the session down again the moment the plugin
1192
+ * unloads (measured: the same call through the root context leaves it alive).
1193
+ * The Host's resolver owns it instead, and it also composes the preset the
1194
+ * session recorded — so a woken agent comes back with the toolset its history
1195
+ * was produced under, not an empty one.
1196
+ */
1197
+ async wake(payload) {
1198
+ if (payload.resume !== true) return { reason: "session-not-live" };
1199
+ if (!this.allowResume) return { reason: "resume-refused" };
1200
+ const lookup = this.ctx.get("typert")?.lookups.get("agent");
1201
+ if (lookup === void 0) return { reason: "session-not-live" };
1202
+ try {
1203
+ const resolved = await lookup.resolve(payload.sessionId);
1204
+ if (resolved === void 0 || resolved === null) return { reason: "session-not-live" };
1205
+ return { agent: resolved };
1206
+ } catch (error) {
1207
+ this.ctx.logger.info(`interconnect: resume refused for ${payload.sessionId}: ${error instanceof Error ? error.message : String(error)}`);
1208
+ return { reason: "resume-failed" };
1209
+ }
1210
+ }
1211
+ /**
1212
+ * Deliver one message to a live local session, waking a persisted one only
1213
+ * when the sender asked and this receiver allows it.
1214
+ */
1215
+ async deliver(payload) {
1216
+ let agent = this.ctx.agents.get(payload.sessionId);
1180
1217
  if (agent === void 0) {
1181
- return { delivered: false, instance: this.instanceId, reason: "session-not-live" };
1218
+ const woken = await this.wake(payload);
1219
+ if ("reason" in woken) {
1220
+ return { delivered: false, instance: this.instanceId, reason: woken.reason };
1221
+ }
1222
+ agent = woken.agent;
1223
+ }
1224
+ if (hasApiRemoteSubagentOwner(this.ctx, agent.session, agent)) {
1225
+ return { delivered: false, instance: this.instanceId, reason: "session-owned-by-subagent" };
1182
1226
  }
1183
1227
  const message = createUserMessage({
1184
1228
  source: {
@@ -7,6 +7,7 @@ import {
7
7
  } from "@deepseek-ai/dsh-host-apiproxy/api";
8
8
  import { credentialRef } from "@deepseek-ai/dsh-credentials";
9
9
  import { boundContextSummary, createUserMessage } from "@deepseek-ai/dsh-llm";
10
+ import { hasApiRemoteSubagentOwner } from "@deepseek-ai/dsh-api-remotes";
10
11
 
11
12
  // ../.dsh/source/staging-20260807T143236Z/vendor/cosmokit/src/misc.ts
12
13
  function isNullable(value) {
@@ -848,7 +849,8 @@ var linkFrameSchema = Schema.union([
848
849
  var sendPayloadSchema = Schema.object({
849
850
  sessionId: Schema.string(),
850
851
  text: Schema.string(),
851
- delivery: Schema.union([Schema.const("followup"), Schema.const("steer"), Schema.const("inject")])
852
+ delivery: Schema.union([Schema.const("followup"), Schema.const("steer"), Schema.const("inject")]),
853
+ resume: Schema.boolean()
852
854
  });
853
855
  var eventPayloadSchema = Schema.object({
854
856
  sender: Schema.string(),
@@ -860,11 +862,13 @@ var InterconnectService = class extends Service {
860
862
  instanceId: Schema.string().default("dsh"),
861
863
  requestTimeoutMs: Schema.natural().max(6e4).default(1e4),
862
864
  peers: Schema.array(Schema.string()).default([]),
863
- delivery: Schema.union([Schema.const("followup"), Schema.const("steer"), Schema.const("inject")]).default("followup")
865
+ delivery: Schema.union([Schema.const("followup"), Schema.const("steer"), Schema.const("inject")]).default("followup"),
866
+ allowResume: Schema.boolean().default(true)
864
867
  });
865
868
  instanceId;
866
869
  requestTimeoutMs;
867
870
  delivery;
871
+ allowResume;
868
872
  peers = /* @__PURE__ */ new Set();
869
873
  subscriptions = [];
870
874
  server = new WebSocketServer({ noServer: true });
@@ -878,6 +882,7 @@ var InterconnectService = class extends Service {
878
882
  this.instanceId = config.instanceId;
879
883
  this.requestTimeoutMs = config.requestTimeoutMs;
880
884
  this.delivery = config.delivery ?? "followup";
885
+ this.allowResume = config.allowResume ?? true;
881
886
  for (const peer of config.peers ?? []) this.peers.add(trimBase(peer));
882
887
  const route = {
883
888
  kind: "prefix",
@@ -949,7 +954,8 @@ var InterconnectService = class extends Service {
949
954
  const payload = {
950
955
  sessionId: request.sessionId,
951
956
  text: request.text,
952
- ...request.delivery === void 0 ? {} : { delivery: request.delivery }
957
+ ...request.delivery === void 0 ? {} : { delivery: request.delivery },
958
+ ...request.resume === void 0 ? {} : { resume: request.resume }
953
959
  };
954
960
  const result = await this.post(request.baseUrl, "send", payload);
955
961
  return result ?? { delivered: false, instance: this.instanceId, reason: "unreachable" };
@@ -1088,7 +1094,7 @@ var InterconnectService = class extends Service {
1088
1094
  return;
1089
1095
  }
1090
1096
  try {
1091
- const value = this.runEndpoint(endpoint, parsed.data.payload);
1097
+ const value = await this.runEndpoint(endpoint, parsed.data.payload);
1092
1098
  writeFull(res, rpcId, { ok: true, value });
1093
1099
  } catch (error) {
1094
1100
  if (error instanceof UnknownEndpointError) {
@@ -1113,7 +1119,7 @@ var InterconnectService = class extends Service {
1113
1119
  }
1114
1120
  }
1115
1121
  /** Dispatch one authenticated business call. */
1116
- runEndpoint(endpoint, payload) {
1122
+ async runEndpoint(endpoint, payload) {
1117
1123
  if (endpoint === "ping") {
1118
1124
  return { pong: true, instance: this.instanceId };
1119
1125
  }
@@ -1157,7 +1163,8 @@ var InterconnectService = class extends Service {
1157
1163
  * matches how the Host's own session listing treats its projection column.
1158
1164
  */
1159
1165
  listSessions() {
1160
- const sessions = this.ctx.agents.list().map((agent) => {
1166
+ const reachable = this.ctx.agents.list().filter((agent) => !hasApiRemoteSubagentOwner(this.ctx, agent.session, agent));
1167
+ const sessions = reachable.map((agent) => {
1161
1168
  let title;
1162
1169
  try {
1163
1170
  const snapshot = this.ctx.get("sessionProjections")?.snapshot(agent.session);
@@ -1174,11 +1181,48 @@ var InterconnectService = class extends Service {
1174
1181
  });
1175
1182
  return { sessions, instance: this.instanceId };
1176
1183
  }
1177
- /** Deliver one message to a live local session, if it exists. */
1178
- deliver(payload) {
1179
- const agent = this.ctx.agents.get(payload.sessionId);
1184
+ /**
1185
+ * Resolve a session that is not currently live, when the sender asked to wake
1186
+ * it. Returns the agent, or the reason it stays undelivered.
1187
+ *
1188
+ * The resume is delegated to the Host's configured `agent` lookup rather than
1189
+ * calling `ctx.agents.resume()` here, and that is the whole point: a handle
1190
+ * from `resume()` is owned by the CALLING context, so resuming on this
1191
+ * plugin's fiber would tear the session down again the moment the plugin
1192
+ * unloads (measured: the same call through the root context leaves it alive).
1193
+ * The Host's resolver owns it instead, and it also composes the preset the
1194
+ * session recorded — so a woken agent comes back with the toolset its history
1195
+ * was produced under, not an empty one.
1196
+ */
1197
+ async wake(payload) {
1198
+ if (payload.resume !== true) return { reason: "session-not-live" };
1199
+ if (!this.allowResume) return { reason: "resume-refused" };
1200
+ const lookup = this.ctx.get("typert")?.lookups.get("agent");
1201
+ if (lookup === void 0) return { reason: "session-not-live" };
1202
+ try {
1203
+ const resolved = await lookup.resolve(payload.sessionId);
1204
+ if (resolved === void 0 || resolved === null) return { reason: "session-not-live" };
1205
+ return { agent: resolved };
1206
+ } catch (error) {
1207
+ this.ctx.logger.info(`interconnect: resume refused for ${payload.sessionId}: ${error instanceof Error ? error.message : String(error)}`);
1208
+ return { reason: "resume-failed" };
1209
+ }
1210
+ }
1211
+ /**
1212
+ * Deliver one message to a live local session, waking a persisted one only
1213
+ * when the sender asked and this receiver allows it.
1214
+ */
1215
+ async deliver(payload) {
1216
+ let agent = this.ctx.agents.get(payload.sessionId);
1180
1217
  if (agent === void 0) {
1181
- return { delivered: false, instance: this.instanceId, reason: "session-not-live" };
1218
+ const woken = await this.wake(payload);
1219
+ if ("reason" in woken) {
1220
+ return { delivered: false, instance: this.instanceId, reason: woken.reason };
1221
+ }
1222
+ agent = woken.agent;
1223
+ }
1224
+ if (hasApiRemoteSubagentOwner(this.ctx, agent.session, agent)) {
1225
+ return { delivered: false, instance: this.instanceId, reason: "session-owned-by-subagent" };
1182
1226
  }
1183
1227
  const message = createUserMessage({
1184
1228
  source: {