dsh-session-bridge 0.3.2-alpha.2 → 0.3.2-alpha.3

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
@@ -16,18 +16,26 @@ action does.
16
16
  session (top-level UI session) in the current workspace, or in another
17
17
  workspace when you pass `workspaceId` / `cwd`. It can send one first prompt
18
18
  and optionally block until the first reply. Provider / model / reasoning
19
- effort are inherited from the calling session by default.
19
+ effort are inherited from the calling session by default. An async create
20
+ (no `waitForReply`) returns a `sinceSeq` anchor for a later precise `wait`.
20
21
  - **Send messages to any session.** `session_bridge_send` appends a turn
21
22
  (`mode=queue`) or injects steering into the running step (`mode=steer`), and
22
- can optionally wait for the next reply.
23
+ can optionally wait for the next reply. An async send also returns a
24
+ `sinceSeq` anchor.
23
25
  - **Wait for a reply or a segment.** `session_bridge_wait` blocks until new
24
- assistant output appears after a given seq: `waitFor=reply` (default) returns
25
- as soon as a new **text** reply is readable; `waitFor=segment` returns as
26
- soon as any new **completed output step** appears (an `assistant/message` —
27
- text, reasoning, or tool-call turn), *without* waiting for the whole turn, so
28
- you can observe output paragraph by paragraph as it is produced. With
29
- `requireTurnEnd` it additionally waits for the turn to settle.
30
- Timeout / abort return the partial result rather than throwing.
26
+ assistant output appears after `sinceSeq` (default: the latest event seq at
27
+ call time): `waitFor=reply` (default) returns as soon as a new **text** reply
28
+ is readable; `waitFor=segment` returns as soon as any new **completed output
29
+ step** appears (an `assistant/message` — text, reasoning, or tool-call turn),
30
+ *without* waiting for the whole turn, so you can observe output paragraph by
31
+ paragraph as it is produced. With `requireTurnEnd` it additionally waits for
32
+ the turn to settle. Timeout / abort return the partial result rather than
33
+ throwing. **An already-landed reply is never lost**: when no new output
34
+ arrives within the budget, the latest PRE-EXISTING reply/segment is returned
35
+ with `stale: true` (no more `(no text)`). To retrieve exactly "the reply to
36
+ what I sent", pass the `sinceSeq` returned by `session_bridge_send` /
37
+ `session_bridge_create` (works regardless of caller latency); `sinceSeq: -1`
38
+ counts existing events too, i.e. the anchor for a brand-new session.
31
39
  - **Read any session.** `session_bridge_read` folds a session's event log into
32
40
  readable rows — live or offline (from persistence) — with `sinceSeq` paging,
33
41
  role filtering, and a `limit` (default 20, max 100).
@@ -44,7 +52,9 @@ action does.
44
52
  working directory. Bridge-registered titles act as aliases.
45
53
  - **Monitor and schedule a main task.** `session_bridge_status` reads a
46
54
  session's real-time progress (running/idle, open turn, time since the last
47
- event for stall detection, pending work, latest reply). `session_bridge_cancel`
55
+ event for stall detection, pending work, latest reply); only a **running**
56
+ session is flagged `[STALLED]` (a quiet idle session is not stuck — same rule
57
+ as the watchdog). `session_bridge_cancel`
48
58
  stops a running session. `session_bridge_monitor_start` runs a **background
49
59
  watchdog loop** that polls the task, nudges it when it stalls, corrects it
50
60
  when it drifts, terminates it after it stays stuck, and wraps up when it
@@ -68,7 +78,9 @@ action does.
68
78
  | Making progress | Reset the stall counter (steady) |
69
79
 
70
80
  The watchdog only treats **running** sessions as stalled, so a finished or idle
71
- task is wrapped up rather than nudged forever. Logs go to
81
+ task is wrapped up rather than nudged forever (the `[STALLED]` marker in
82
+ `session_bridge_status` follows the same rule and is shown for running sessions
83
+ only). Logs go to
72
84
  `~/.dsh/super-injector/dsh-session-bridge-monitor.log` (overridable).
73
85
 
74
86
  Control it with `session_bridge_monitor_start` / `_stop` / `_list`.
@@ -122,6 +134,9 @@ bash scripts/build.sh && npm run build:client
122
134
  # type-check src/ against the dsh that is actually installed (no checkout needed)
123
135
  npm run check:compat
124
136
 
137
+ # regression tests for the wait/stall core logic (Node type stripping, no deps)
138
+ npm test
139
+
125
140
  # via the injector toolchain
126
141
  dev_build_plugin dsh-session-bridge
127
142
  ```
@@ -248,14 +263,14 @@ registration and junction; not re-assembled on restart).
248
263
 
249
264
  | Tool | What it does |
250
265
  |---|---|
251
- | `session_bridge_create` | Create a main session (current or another workspace via `workspaceId` / `cwd`); optional first prompt + `waitForReply`. |
252
- | `session_bridge_send` | Send a message (`mode=queue`/`steer`); optional wait-for-reply. |
253
- | `session_bridge_wait` | Wait for new output after `sinceSeq`: `waitFor=reply` (text) or `waitFor=segment` (any completed step, no full-turn wait); optional `requireTurnEnd`. |
266
+ | `session_bridge_create` | Create a main session (current or another workspace via `workspaceId` / `cwd`); optional first prompt + `waitForReply`; async creates return a `sinceSeq` anchor. |
267
+ | `session_bridge_send` | Send a message (`mode=queue`/`steer`); optional wait-for-reply; async sends return a `sinceSeq` anchor. |
268
+ | `session_bridge_wait` | Wait for new output after `sinceSeq` (default: latest seq at call time; `-1` counts existing events): `waitFor=reply` (text) or `waitFor=segment` (any completed step, no full-turn wait); optional `requireTurnEnd`; falls back to the pre-existing reply with `stale` when nothing new arrives. |
254
269
  | `session_bridge_read` | Read messages — live or offline; `sinceSeq` paging, `role` filter, `limit`. |
255
270
  | `session_bridge_segments` | Read completed output segments (each finished assistant step) incrementally by paragraph — live or offline. |
256
271
  | `session_bridge_resume` | Bring a persisted session back online (idempotent). |
257
272
  | `session_bridge_find` | Find sessions by title / id / workspace / directory across workspaces. |
258
- | `session_bridge_status` | Read a session's live progress (running, open turn, stall detection, pending work, latest reply) plus live/finalized chain-of-thought (`reasoning` param). |
273
+ | `session_bridge_status` | Read a session's live progress (running, open turn, stall detection, pending work, latest reply) plus live/finalized chain-of-thought (`reasoning` param); `[STALLED]` is shown for running sessions only. |
259
274
  | `session_bridge_cancel` | Stop a running session (abort active turn; clear queued/steering work unless `keepInbox`). |
260
275
  | `session_bridge_monitor_start` | Start a background watchdog on a main session (poll, nudge, correct, cancel, wrap up); supports chain-of-thought `coRules` (e.g. reasoning not-contains "I'm" → cancel). |
261
276
  | `session_bridge_monitor_stop` | Stop a watchdog (keep the session itself running). |
@@ -264,7 +279,7 @@ registration and junction; not re-assembled on restart).
264
279
  | `session_bridge_archived` | List the archive set, optionally resolving titles. |
265
280
 
266
281
  All tools return lossless JSON; wait-style tools never throw on timeout — they
267
- return a `timedOut` / `aborted` flag.
282
+ return a `timedOut` / `aborted` / `stale` flag.
268
283
 
269
284
  ## Project layout
270
285
 
@@ -277,6 +292,7 @@ src/
277
292
  registry.ts bridge-side title/workspace registry (~/.dsh/session-bridge-registry.json)
278
293
  scripts/
279
294
  build.sh type-check + link types against the DSH checkout
295
+ test-bridge-core.mjs wait/stall regression tests (npm test)
280
296
  ```
281
297
 
282
298
  ## Lifecycle and unloading
package/README.zh.md CHANGED
@@ -11,14 +11,19 @@
11
11
 
12
12
  - **创建真实 DSH 会话。** `session_bridge_create` 在当前工作区创建新的主会话(顶层 UI 会话),
13
13
  传 `workspaceId` / `cwd` 则跨工作区;可选发送首条 prompt 并阻塞等待首条回复。provider / model /
14
- reasoning effort 默认继承调用会话。
14
+ reasoning effort 默认继承调用会话。异步创建(不带 `waitForReply`)会返回 `sinceSeq` 锚点,
15
+ 之后可用它精确地 `session_bridge_wait` 取回首条回复。
15
16
  - **向任意会话发消息。** `session_bridge_send` 追加一轮(`mode=queue`)或向运行中的步骤注入
16
- steering(`mode=steer`),可选等待下一条回复。
17
- - **等待回复或段落。** `session_bridge_wait` 阻塞直至 `sinceSeq` 之后出现新的 assistant 输出:
18
- `waitFor=reply`(默认)在有新的**文本**回复可读时立即返回;`waitFor=segment` 在任意新**已完成
19
- 输出步骤**出现时立即返回(一个 `assistant/message`——文本、推理或工具调用段),*无需*等整个
20
- turn 结束,从而可以按段落逐段观察输出。开 `requireTurnEnd` 则同时等待回合收尾。
21
- 超时 / 中止返回部分结果,而非抛错。
17
+ steering(`mode=steer`),可选等待下一条回复;异步发送同样返回 `sinceSeq` 锚点。
18
+ - **等待回复或段落。** `session_bridge_wait` 阻塞直至 `sinceSeq` 之后出现新的 assistant 输出
19
+ (默认 `sinceSeq` = 调用时刻的最新事件 seq):`waitFor=reply`(默认)在有新的**文本**回复可读时
20
+ 立即返回;`waitFor=segment` 在任意新**已完成输出步骤**出现时立即返回(一个 `assistant/message`
21
+ ——文本、推理或工具调用段),*无需*等整个 turn 结束,从而可以按段落逐段观察输出。开
22
+ `requireTurnEnd` 则同时等待回合收尾。超时 / 中止返回部分结果,而非抛错。
23
+ **回复早已落地也不会丢**:预算内没等到新输出时,会返回预算前就存在的最新回复/段落并置
24
+ `stale: true`(不会再出现 `(no text)`)。要精确取回"某次发送之后的回复",把 `session_bridge_send` /
25
+ `session_bridge_create` 异步返回的 `sinceSeq` 作为锚点传进来即可(与调用方延迟无关);
26
+ `sinceSeq: -1` 表示"连既有事件也算",即新建会话的锚点。
22
27
  - **读取任意会话。** `session_bridge_read` 把会话事件日志折叠为可读行——live 或离线(持久化)均可;
23
28
  支持 `sinceSeq` 分页、role 过滤、`limit`(默认 20,最大 100)。
24
29
  - **按段落读取输出。** `session_bridge_segments` 返回会话的**已完成输出段落**——每个已完成的
@@ -29,7 +34,8 @@
29
34
  - **查找会话。** `session_bridge_find` 跨全部工作区按 标题 / id / workspace / 目录 匹配,返回
30
35
  live/running 状态、标题、工作目录;bridge 登记的标题作为别名参与匹配。
31
36
  - **监控并调度主任务。** `session_bridge_status` 读取会话实时进度(running/idle、是否 `openTurn`、
32
- 距最近事件毫秒数做卡住检测、待处理消息、最新回复);`session_bridge_cancel` 停止一个运行中的会话;
37
+ 距最近事件毫秒数做卡住检测、待处理消息、最新回复);只有 **running** 会话才会被标 `[STALLED]`
38
+ (空闲会话没有进展是正常状态,与守护循环判定一致)。`session_bridge_cancel` 停止一个运行中的会话;
33
39
  `session_bridge_monitor_start` 运行一个**后台守护循环**,轮询任务、卡住时催办、偏离时纠偏、
34
40
  持续卡住则终止、完成即收尾。
35
41
  - **归档会话。** `session_bridge_archive` 把会话加入 DSH workspace 归档集合(从所有分组视图隐藏,
@@ -48,7 +54,8 @@
48
54
  | 连续卡住 ≥ `maxStuckCycles` | `cancel` 终止 |
49
55
  | 正常推进 | 重置卡住计数(steady) |
50
56
 
51
- 守护只对 **running** 会话判定"卡住",因此已完成/空闲的任务会被收尾而非无限催办。日志写入
57
+ 守护只对 **running** 会话判定"卡住",因此已完成/空闲的任务会被收尾而非无限催办
58
+ (`session_bridge_status` 的 `[STALLED]` 标注同理,只对 running 会话显示)。日志写入
52
59
  `~/.dsh/super-injector/dsh-session-bridge-monitor.log`(可用 `logFile` 覆盖)。
53
60
 
54
61
  用 `session_bridge_monitor_start` / `_stop` / `_list` 控制。
@@ -90,6 +97,9 @@ bash scripts/build.sh && npm run build:client
90
97
  # 针对"实际安装的 dsh"做类型检查(不需要 checkout)
91
98
  npm run check:compat
92
99
 
100
+ # 核心 wait/卡住判定的回归测试(Node 类型擦除直跑 src/core.ts,零依赖)
101
+ npm test
102
+
93
103
  # 或经注入器工具链
94
104
  dev_build_plugin dsh-session-bridge
95
105
  ```
@@ -194,14 +204,14 @@ dev_inject_plugin D:\code\dsh-session-bridge
194
204
 
195
205
  | 工具 | 作用 |
196
206
  |---|---|
197
- | `session_bridge_create` | 创建主会话(当前或其它工作区,经 `workspaceId` / `cwd`);可选首条 prompt + `waitForReply`。 |
198
- | `session_bridge_send` | 发消息(`mode=queue`/`steer`);可选等待回复。 |
199
- | `session_bridge_wait` | 等待 `sinceSeq` 之后新输出:`waitFor=reply`(文本)或 `waitFor=segment`(任一已完成步骤即返回,无需等整个 turn);可选 `requireTurnEnd`。 |
207
+ | `session_bridge_create` | 创建主会话(当前或其它工作区,经 `workspaceId` / `cwd`);可选首条 prompt + `waitForReply`;异步时返回 `sinceSeq` 锚点。 |
208
+ | `session_bridge_send` | 发消息(`mode=queue`/`steer`);可选等待回复;异步时返回 `sinceSeq` 锚点。 |
209
+ | `session_bridge_wait` | 等待 `sinceSeq` 之后新输出(默认 = 调用时刻最新 seq,`-1` = 从头发算):`waitFor=reply`(文本)或 `waitFor=segment`(任一已完成步骤即返回,无需等整个 turn);可选 `requireTurnEnd`;零新输出时回落既有回复并置 `stale`。 |
200
210
  | `session_bridge_read` | 读取消息 —— live 或离线;`sinceSeq` 分页、`role` 过滤、`limit`。 |
201
211
  | `session_bridge_segments` | 增量读取已完成输出段落(每个已完成的 assistant 步骤)—— live 或离线。 |
202
212
  | `session_bridge_resume` | 让持久化会话重新上线(幂等)。 |
203
213
  | `session_bridge_find` | 跨工作区按 标题 / id / workspace / 目录 查找会话。 |
204
- | `session_bridge_status` | 读取会话实时进度(running、openTurn、卡住检测、待处理、最新回复)及实时/已定型思维链(`reasoning` 参数)。 |
214
+ | `session_bridge_status` | 读取会话实时进度(running、openTurn、卡住检测、待处理、最新回复)及实时/已定型思维链(`reasoning` 参数);`[STALLED]` 仅对 running 会话显示。 |
205
215
  | `session_bridge_cancel` | 停止运行中的会话(中止活动 turn;`keepInbox` 保留排队/steering 输入)。 |
206
216
  | `session_bridge_monitor_start` | 对一个主会话启动后台守护(轮询、催办、纠偏、终止、收尾);支持思维链 `coRules`(如 reasoning not-contains "I'm" → cancel)。 |
207
217
  | `session_bridge_monitor_stop` | 停止守护(会话本身不终止)。 |
@@ -209,7 +219,7 @@ dev_inject_plugin D:\code\dsh-session-bridge
209
219
  | `session_bridge_archive` | 归档会话(从分组隐藏;历史与位置保留)。 |
210
220
  | `session_bridge_archived` | 列出归档集合,可选解析标题。 |
211
221
 
212
- 所有工具输出 lossless JSON;等待类工具超时不抛错,返回 `timedOut` / `aborted` 标记。
222
+ 所有工具输出 lossless JSON;等待类工具超时不抛错,返回 `timedOut` / `aborted` / `stale` 标记。
213
223
 
214
224
  ## 项目结构
215
225
 
@@ -222,6 +232,7 @@ src/
222
232
  registry.ts 桥侧标题/workspace 登记表(~/.dsh/session-bridge-registry.json)
223
233
  scripts/
224
234
  build.sh 类型检查 + 链接 DSH checkout 类型
235
+ test-bridge-core.mjs wait/卡住判定回归测试(npm test)
225
236
  ```
226
237
 
227
238
  ## 生命周期与卸载
package/dsh.plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "dsh-external/dsh-session-bridge",
3
- "version": "0.3.2-alpha.2",
3
+ "version": "0.3.2-alpha.3",
4
4
  "main": "./lib/index.js",
5
5
  "description": "会话桥:创建主会话 / 向任意会话发消息 / 等待回复 / 读取消息 / 按名或 id 查找会话(支持跨工作区);另含监控/调度主任务与归档会话。",
6
6
  "engines": {
package/lib/index.js CHANGED
@@ -9535,8 +9535,11 @@ function sleep(ms) {
9535
9535
  * 因此默认不再用 turn/end 作门控;需要完整收尾语义时用 requireTurnEnd:true 显式开启,
9536
9536
  * 此时才会等到其后出现 turn/end。
9537
9537
  *
9538
- * 返回时 message 为"最新的带文本 assistant 行",否则为最新 assistant 行(可能无文本,
9539
- * 如纯工具调用中间态);超时/中止返回已收集内容。
9538
+ * 返回的 message:默认是 baseline 之后最新的一条**带文本** assistant 行;整个等待
9539
+ * 窗口内没有等到新文本回复时,回落为 baseline 及之前最新的一条带文本回复并置
9540
+ * stale:true(绝不把更晚的无文本中间行当回复返回 —— 那正是渲染层打印 "(no text)"
9541
+ * 的来源)。segment 模式同理:新段落优先,否则回落既有段落。
9542
+ * 超时/中止返回已收集内容;timedOut 表示"本次等待要求的输出未在预算内出现"。
9540
9543
  */
9541
9544
  async function waitForReply(opts) {
9542
9545
  const started = Date.now();
@@ -9573,7 +9576,25 @@ async function waitForReply(opts) {
9573
9576
  if (Date.now() >= deadline) break;
9574
9577
  await sleep(100);
9575
9578
  }
9576
- let message = waitForSegment && segment !== null ? {
9579
+ const observedNew = waitForSegment ? segment !== null : textReply !== null;
9580
+ const sawNewRow = latest !== null;
9581
+ let stale = false;
9582
+ if (waitForSegment) {
9583
+ if (segment === null) {
9584
+ const prev = lastSegmentUpTo(sessionEvents(opts.session), opts.baselineSeq);
9585
+ if (prev !== null) {
9586
+ segment = prev;
9587
+ stale = true;
9588
+ }
9589
+ }
9590
+ } else if (textReply === null) {
9591
+ const prev = lastTextRowUpTo(foldMessages(sessionEvents(opts.session)), opts.baselineSeq);
9592
+ if (prev !== null) {
9593
+ textReply = prev;
9594
+ stale = true;
9595
+ }
9596
+ }
9597
+ const message = waitForSegment && segment !== null ? {
9577
9598
  seq: segment.seq,
9578
9599
  time: segment.time,
9579
9600
  role: "assistant",
@@ -9581,16 +9602,34 @@ async function waitForReply(opts) {
9581
9602
  ...segment.text !== void 0 ? { text: segment.text } : {},
9582
9603
  ...segment.reasoning !== void 0 ? { reasoning: segment.reasoning } : {},
9583
9604
  ...segment.toolCalls.length > 0 ? { toolCalls: segment.toolCalls } : {}
9584
- } : textReply ?? latest;
9605
+ } : textReply;
9585
9606
  return {
9586
9607
  message,
9587
9608
  seq: message === null ? opts.baselineSeq : message.seq,
9588
9609
  turnEnded,
9589
- timedOut: requireTurnEnd ? latest !== null && !turnEnded : waitForSegment ? segment === null : textReply === null,
9610
+ timedOut: requireTurnEnd ? !(sawNewRow && turnEnded) : !observedNew,
9611
+ stale,
9590
9612
  aborted: opts.signal !== void 0 && opts.signal.aborted,
9591
9613
  waitedMs: Date.now() - started
9592
9614
  };
9593
9615
  }
9616
+ /** seq 及之前最新一个已完成输出段落(wait 零新输出时回落既有段落)。 */
9617
+ function lastSegmentUpTo(events, seq) {
9618
+ const segs = segmentsSince(events);
9619
+ for (let i = segs.length - 1; i >= 0; i -= 1) {
9620
+ const seg = segs[i];
9621
+ if (seg !== void 0 && seg.seq <= seq) return seg;
9622
+ }
9623
+ return null;
9624
+ }
9625
+ /** seq 及之前最新一条带文本的 assistant 行(wait 零新文本回复时回落既有回复)。 */
9626
+ function lastTextRowUpTo(rows, seq) {
9627
+ for (let i = rows.length - 1; i >= 0; i -= 1) {
9628
+ const row = rows[i];
9629
+ if (row !== void 0 && row.role === "assistant" && row.text !== void 0 && row.seq <= seq) return row;
9630
+ }
9631
+ return null;
9632
+ }
9594
9633
  /**
9595
9634
  * 解析目标工作目录:workspaceId(注册表)> cwd(绝对路径/任意拼写)> 调用者会话 cwd。
9596
9635
  * 跨工作区由 workspaceId / cwd 任意指定实现。
@@ -9642,6 +9681,14 @@ async function attachSessionToWorkspace(ctx, sessionId, cwd) {
9642
9681
  return workspace.id;
9643
9682
  }
9644
9683
  /**
9684
+ * 卡住判定(唯一真源,status 渲染与监控 watchdog 共用):只有 **running** 会话才可能
9685
+ * "卡住" —— 空闲/已收尾的会话没有进展是正常状态,不能报 stall。阈值语义为"超过"
9686
+ * (严格大于),与工具描述/README 的 "more than stalledMs" 一致。
9687
+ */
9688
+ function isStalled(running, stalledMs, thresholdMs) {
9689
+ return running === "running" && stalledMs !== null && stalledMs > thresholdMs;
9690
+ }
9691
+ /**
9645
9692
  * 计算一个 live 会话的监控快照(agent 状态 + 事件折叠出的进度/卡住事实)。
9646
9693
  * 纯读,无副作用。用于 `session_bridge_status`。
9647
9694
  */
@@ -9861,7 +9908,7 @@ var SessionMonitor = class {
9861
9908
  this.stopWithLog(sessionId);
9862
9909
  return;
9863
9910
  }
9864
- if (snapshot.running === "running" && snapshot.stalledMs !== null && snapshot.stalledMs > (entry.config.stalledMs ?? 6e4)) {
9911
+ if (isStalled(snapshot.running, snapshot.stalledMs, entry.config.stalledMs ?? 6e4)) {
9865
9912
  entry.stuckCount += 1;
9866
9913
  entry.lastActionAt = Date.now();
9867
9914
  if (this.shouldCancel(entry)) {
@@ -14141,6 +14188,14 @@ function liveAgent(env, sessionId) {
14141
14188
  function liveAgents(env) {
14142
14189
  return env.ctx.agents.list();
14143
14190
  }
14191
+ /** 等待结果里的状态附注行(wait / send / create 三处渲染共用)。 */
14192
+ function waitNotes(reply) {
14193
+ const notes = [];
14194
+ if (reply.timedOut === true) notes.push("[wait timed out]");
14195
+ if (reply.stale === true) notes.push("[stale] pre-existing reply — nothing new arrived during the wait");
14196
+ if (reply.aborted === true) notes.push("[wait aborted]");
14197
+ return notes;
14198
+ }
14144
14199
  /** 渲染等待结果为 JSON 友好值。 */
14145
14200
  function renderWait(wait) {
14146
14201
  const row = wait.message;
@@ -14156,6 +14211,7 @@ function renderWait(wait) {
14156
14211
  seq: wait.seq,
14157
14212
  turnEnded: wait.turnEnded,
14158
14213
  timedOut: wait.timedOut,
14214
+ ...wait.stale === true ? { stale: true } : {},
14159
14215
  aborted: wait.aborted,
14160
14216
  waitedMs: wait.waitedMs
14161
14217
  };
@@ -14227,9 +14283,10 @@ function registerCreate(env) {
14227
14283
  if (typeof v.cwd === "string") lines.push(`cwd: ${v.cwd}`);
14228
14284
  if (typeof v.title === "string") lines.push(`title: ${v.title}`);
14229
14285
  const reply = v.reply;
14286
+ if (typeof v.sinceSeq === "number") lines.push(`sinceSeq: ${String(v.sinceSeq)} (pass to session_bridge_wait)`);
14230
14287
  if (reply !== void 0) {
14231
14288
  lines.push(`reply: ${String(reply.message?.text ?? "(no text)")}`);
14232
- if (reply.timedOut === true) lines.push("[wait timed out]");
14289
+ lines.push(...waitNotes(reply));
14233
14290
  }
14234
14291
  return [{
14235
14292
  type: "text",
@@ -14296,8 +14353,10 @@ function registerCreate(env) {
14296
14353
  source: "create"
14297
14354
  });
14298
14355
  let reply;
14356
+ let createBaseline;
14299
14357
  if (typeof args.prompt === "string" && args.prompt.trim() !== "") {
14300
14358
  const baseline = maxSeq(sessionEvents(agent.session));
14359
+ createBaseline = baseline;
14301
14360
  agent.followup(userMessage(args.prompt.trim()));
14302
14361
  env.registry.touch(sessionId);
14303
14362
  if (args.waitForReply === true) reply = await maybeWait(env, agent.session, baseline, args, exec.signal);
@@ -14307,6 +14366,7 @@ function registerCreate(env) {
14307
14366
  cwd: targetCwd,
14308
14367
  ...workspaceId === void 0 ? {} : { workspaceId },
14309
14368
  ...typeof args.title === "string" && args.title.trim() !== "" ? { title: args.title.trim() } : {},
14369
+ ...reply === void 0 && createBaseline !== void 0 ? { sinceSeq: createBaseline } : {},
14310
14370
  ...reply === void 0 ? {} : { reply }
14311
14371
  });
14312
14372
  }
@@ -14350,9 +14410,10 @@ function registerSend(env) {
14350
14410
  const v = value;
14351
14411
  const reply = v.reply;
14352
14412
  const lines = ["sent to " + String(v.sessionId)];
14413
+ if (typeof v.sinceSeq === "number") lines.push("sinceSeq: " + String(v.sinceSeq) + " (pass to session_bridge_wait)");
14353
14414
  if (reply !== null && reply !== void 0) {
14354
14415
  lines.push("reply: " + String(reply.message?.text ?? "(no text)"));
14355
- if (reply.timedOut === true) lines.push("[wait timed out]");
14416
+ lines.push(...waitNotes(reply));
14356
14417
  }
14357
14418
  return [{
14358
14419
  type: "text",
@@ -14378,7 +14439,7 @@ function registerSend(env) {
14378
14439
  return asJson({
14379
14440
  accepted: true,
14380
14441
  sessionId: args.sessionId,
14381
- ...reply === void 0 ? {} : { reply }
14442
+ ...reply === void 0 ? { sinceSeq: baseline } : { reply }
14382
14443
  });
14383
14444
  }
14384
14445
  }));
@@ -14473,7 +14534,7 @@ function registerResume(env) {
14473
14534
  function registerWait(env) {
14474
14535
  env.ctx.tools.register(defineTool({
14475
14536
  name: "session_bridge_wait",
14476
- description: "Wait for a session next assistant output: blocks (polling the session log) until a NEW assistant output appears after sinceSeq (default: the latest seq at call time). waitFor=reply returns as soon as a new assistant TEXT reply is readable; waitFor=segment returns as soon as any new COMPLETED output segment appears (an assistant/message step — text, reasoning, or tool-call turn), i.e. it does NOT wait for the whole turn, so you can observe the chain-of-thought/output paragraph by paragraph as it is produced. Returns the output summary, or timedOut/aborted when the deadline or caller cancellation ends the wait. Use it to consume output produced asynchronously by another session (e.g. a session you sent a message to, or one working on its own).",
14537
+ description: "Wait for a session next assistant output: blocks (polling the session log) until a NEW assistant output appears after sinceSeq (default: the latest seq at call time). waitFor=reply returns as soon as a new assistant TEXT reply is readable; waitFor=segment returns as soon as any new COMPLETED output segment appears (an assistant/message step — text, reasoning, or tool-call turn), i.e. it does NOT wait for the whole turn, so you can observe the chain-of-thought/output paragraph by paragraph as it is produced. If no new output arrives within the budget, the latest PRE-EXISTING reply/segment is returned with stale=true (so an already-landed reply is never lost as \"(no text)\"). Returns the output summary, or timedOut/aborted when the deadline or caller cancellation ends the wait. Use it to consume output produced asynchronously by another session (e.g. a session you sent a message to, or one working on its own); to read a reply that may already exist, pass the sinceSeq anchor returned by send/create.",
14477
14538
  parameters: {
14478
14539
  sessionId: {
14479
14540
  type: "string",
@@ -14482,7 +14543,7 @@ function registerWait(env) {
14482
14543
  },
14483
14544
  sinceSeq: {
14484
14545
  type: "number",
14485
- description: "Only replies after this event seq count (default: latest seq at call time)."
14546
+ description: "Only replies after this event seq count (default: latest seq at call time; -1 = count every event, the anchor returned by create for a brand-new session)."
14486
14547
  },
14487
14548
  timeoutMs: {
14488
14549
  type: "number",
@@ -14510,10 +14571,9 @@ function registerWait(env) {
14510
14571
  text: "no reply observed"
14511
14572
  }];
14512
14573
  const message = reply.message;
14513
- const lines = ["reply seq " + String(reply.seq) + ": " + String(message === null ? "(no text)" : message.text ?? "(no text)")];
14574
+ const lines = [message === null ? "no new reply within " + String(reply.waitedMs ?? "?") + "ms (no assistant text in this session)" : "reply seq " + String(reply.seq) + ": " + String(message.text ?? "(no text)")];
14514
14575
  if (typeof reply.turnEnded === "boolean") lines.push("turnEnded: " + String(reply.turnEnded));
14515
- if (reply.timedOut === true) lines.push("[wait timed out]");
14516
- if (reply.aborted === true) lines.push("[wait aborted]");
14576
+ lines.push(...waitNotes(reply));
14517
14577
  return [{
14518
14578
  type: "text",
14519
14579
  text: lines.join("\n")
@@ -14524,28 +14584,19 @@ function registerWait(env) {
14524
14584
  if (typeof args.sessionId !== "string" || args.sessionId.trim() === "") throw new Error("invalid sessionId: expected a non-empty string");
14525
14585
  const agent = liveAgent(env, args.sessionId);
14526
14586
  if (agent === void 0) throw new Error("session " + JSON.stringify(args.sessionId) + " is not live — call session_bridge_resume first (waiting requires a live session)");
14527
- const waitSegment = args.waitFor === "segment";
14528
- let baseline;
14529
- if (typeof args.sinceSeq === "number" && Number.isInteger(args.sinceSeq) && args.sinceSeq >= 0) baseline = args.sinceSeq;
14530
- else if (waitSegment) {
14531
- const segs = segmentsSince(sessionEvents(agent.session));
14532
- baseline = segs.length === 0 ? -1 : segs[segs.length - 1]?.seq ?? -1;
14533
- } else {
14534
- let lastText = -1;
14535
- for (const row of foldMessages(sessionEvents(agent.session))) if (row.text !== void 0) lastText = row.seq;
14536
- baseline = lastText;
14537
- }
14587
+ const baseline = typeof args.sinceSeq === "number" && Number.isInteger(args.sinceSeq) && args.sinceSeq >= -1 ? args.sinceSeq : maxSeq(sessionEvents(agent.session));
14538
14588
  const result = await waitForReply({
14539
14589
  session: agent.session,
14540
14590
  baselineSeq: baseline,
14541
14591
  timeoutMs: clampTimeout(args.timeoutMs),
14542
14592
  signal: exec.signal,
14543
14593
  requireTurnEnd: args.requireTurnEnd === true,
14544
- ...waitSegment ? { waitForSegment: true } : {}
14594
+ ...args.waitFor === "segment" ? { waitForSegment: true } : {}
14545
14595
  });
14546
14596
  env.registry.touch(args.sessionId);
14547
14597
  return asJson({
14548
14598
  sessionId: args.sessionId,
14599
+ sinceSeq: baseline,
14549
14600
  reply: renderWait(result),
14550
14601
  running: agent.status === "running"
14551
14602
  });
@@ -15151,7 +15202,11 @@ function pruneReasoning(snapshot, mode) {
15151
15202
  else delete rest.lastReasoning;
15152
15203
  return rest;
15153
15204
  }
15154
- /** 渲染监控快照为一行摘要:运行态 + openTurn + 卡住/待处理 + 最新回复(+ 思维链预览)。 */
15205
+ /**
15206
+ * 渲染监控快照为一行摘要:运行态 + openTurn + 卡住/待处理 + 最新回复(+ 思维链预览)。
15207
+ * 卡住判定走 core.isStalled(唯一真源):只有 running 会话才可能被标 [STALLED] ——
15208
+ * 空闲/已收尾的会话"很久没事件"是正常状态,不是卡住(与监控 watchdog 一致)。
15209
+ */
15155
15210
  function renderStatus(snapshot, stalledMsThreshold) {
15156
15211
  const lines = [];
15157
15212
  const runLabel = snapshot.running === "running" ? "running" : "idle";
@@ -15159,7 +15214,7 @@ function renderStatus(snapshot, stalledMsThreshold) {
15159
15214
  if (snapshot.openTurn) lines.push(`openTurn: yes (turn #${snapshot.lastTurn})`);
15160
15215
  else lines.push(`openTurn: no (last turn #${snapshot.lastTurn})`);
15161
15216
  if (snapshot.stalledMs !== null) {
15162
- const stalled = snapshot.stalledMs >= stalledMsThreshold;
15217
+ const stalled = isStalled(snapshot.running, snapshot.stalledMs, stalledMsThreshold);
15163
15218
  lines.push(`lastActivity: now-${snapshot.stalledMs}ms${stalled ? " [STALLED]" : ""}`);
15164
15219
  }
15165
15220
  if (snapshot.pendingWork) lines.push(`pendingWork: ${snapshot.nextTurnCount} turn + ${snapshot.nextStepCount} step`);
@@ -15173,7 +15228,7 @@ function renderStatus(snapshot, stalledMsThreshold) {
15173
15228
  function registerStatus(env) {
15174
15229
  env.ctx.tools.register(defineTool({
15175
15230
  name: "session_bridge_status",
15176
- description: "Inspect a session's live progress for monitoring/scheduling. Returns running/idle, whether a turn is open, last turn number, time since the last event (for stall detection), pending queued work, and the latest text reply. It also surfaces the session's chain-of-thought: lastReasoning is the most recent finalized reasoning block, liveReasoning is the in-flight reasoning streamed for the current handled turn (reasoning-delta), and reasoningTail is a compact merged preview. reasoning=none drops all three to keep tokens small. When stalledMsThreshold is given, marks the session as stalled when the time since the last event exceeds it. Pass sessionId of a live session (use session_bridge_find to locate; session_bridge_resume to bring an offline one online). Use this as the \"observe\" step of a monitor→decide→steer/cancel loop.",
15231
+ description: "Inspect a session's live progress for monitoring/scheduling. Returns running/idle, whether a turn is open, last turn number, time since the last event (for stall detection), pending queued work, and the latest text reply. It also surfaces the session's chain-of-thought: lastReasoning is the most recent finalized reasoning block, liveReasoning is the in-flight reasoning streamed for the current handled turn (reasoning-delta), and reasoningTail is a compact merged preview. reasoning=none drops all three to keep tokens small. When stalledMsThreshold is given, marks a RUNNING session as stalled once the time since the last event exceeds it (idle sessions are never flagged — a quiet finished session is not stuck). Pass sessionId of a live session (use session_bridge_find to locate; session_bridge_resume to bring an offline one online). Use this as the \"observe\" step of a monitor→decide→steer/cancel loop.",
15177
15232
  parameters: {
15178
15233
  sessionId: {
15179
15234
  type: "string",
@@ -15182,7 +15237,7 @@ function registerStatus(env) {
15182
15237
  },
15183
15238
  stalledMsThreshold: {
15184
15239
  type: "number",
15185
- description: "Mark the session STALLED when time since the last event exceeds this many ms (default 60000)."
15240
+ description: "Mark a RUNNING session STALLED when time since the last event exceeds this many ms (default 60000); idle sessions are never marked."
15186
15241
  },
15187
15242
  recent: {
15188
15243
  type: "number",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-session-bridge",
3
- "version": "0.3.2-alpha.2",
3
+ "version": "0.3.2-alpha.3",
4
4
  "description": "会话桥(dsh-session-bridge):通过提示词创建新的主会话(顶层 UI 会话)、向任意会话发送消息、等待会话的下一条回复、读取会话消息,并按会话名或 id 跨工作区查找会话;此外支持监控/调度主任务与归档会话。",
5
5
  "private": false,
6
6
  "type": "module",
@@ -83,6 +83,7 @@
83
83
  "build": "bash scripts/build.sh",
84
84
  "build:client": "tsdown",
85
85
  "check:compat": "node scripts/check-dsh-compat.mjs",
86
+ "test": "node --experimental-strip-types scripts/test-bridge-core.mjs",
86
87
  "typecheck": "tsc -p tsconfig.json --noEmit"
87
88
  }
88
89
  }
@@ -79,6 +79,13 @@ function resolveScopeDir(argv) {
79
79
  }
80
80
  } catch { /* dsh not on PATH; the other anchors decide */ }
81
81
 
82
+ // Anchor 3 (last resort): this repository's own install. CI pins the
83
+ // @deepseek-ai packages through pnpm overrides and installs no DSH app, so
84
+ // the scope only exists here; probing it keeps check:compat runnable in CI.
85
+ // A developer machine resolves one of the anchors above first, so the
86
+ // *running* harness keeps priority there.
87
+ candidates.push(join(ROOT, 'node_modules', '@deepseek-ai'))
88
+
82
89
  for (const candidate of candidates) if (isScopeDir(candidate)) return candidate
83
90
  return undefined
84
91
  }
@@ -115,6 +122,13 @@ function dshVersionOf(scopeDir) {
115
122
  const manifest = readJson(candidate)
116
123
  if (manifest?.name === '@deepseek-ai/dsh' && typeof manifest.version === 'string') return manifest.version
117
124
  }
125
+ // Repo-local install (CI overrides): there is no `dsh` app package, but the
126
+ // release versions every dsh-* package together, so any of them names the
127
+ // harness version the types and the bundle were resolved from.
128
+ for (const pkg of ['dsh-tools', 'dsh-session', 'dsh-agent']) {
129
+ const manifest = readJson(join(scopeDir, pkg, 'package.json'))
130
+ if (typeof manifest?.version === 'string') return manifest.version
131
+ }
118
132
  return undefined
119
133
  }
120
134
 
@@ -142,8 +156,11 @@ function artifactProvenance() {
142
156
  // Source checkout: <root>/packages/... or <root>/vendor/... (relative to the map in lib/).
143
157
  const checkout = source.match(/^(.*?)[/\\](?:packages|vendor)[/\\]/)
144
158
  if (checkout !== null) { roots.add(resolve(ROOT, 'lib', checkout[1])); continue }
145
- // Registry install: .../.pnpm/@deepseek-ai+<name>@<version>[_hash]/node_modules/...
146
- const registry = source.match(/[\\/]\.pnpm[\\/]@deepseek-ai\+[a-z0-9-]+@([^\\/]+)[\\/]node_modules/)
159
+ // Registry install: .../.pnpm/@deepseek-ai+dsh-<name>@<version>[_hash]/node_modules/...
160
+ // Only dsh-* packages name the harness release; sibling @deepseek-ai
161
+ // packages (cordis, schemastery) are versioned independently, so counting
162
+ // them would make every CI build look like a provenance mismatch.
163
+ const registry = source.match(/[\\/]\.pnpm[\\/]@deepseek-ai\+dsh-[a-z0-9-]+@([^\\/]+)[\\/]node_modules/)
147
164
  if (registry !== null) {
148
165
  const version = registry[1].replace(/_.*$/, '')
149
166
  if (/^\d/.test(version)) versions.add(version)
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Regression tests for the session-bridge core wait/stall logic (GitHub issue #1):
4
+ *
5
+ * Bug 1 — session_bridge_wait 返回 "(no text)" 并跑满超时,即使回复早已存在。
6
+ * Bug 2 — session_bridge_status 把空闲会话标成 [STALLED]。
7
+ *
8
+ * Runs against src/core.ts directly (Node type stripping — no build, no deps, no
9
+ * host/Dsh runtime needed): `npm test`. The fake Session only has to expose the
10
+ * read path core.sessionEvents() probes (`snapshotEvents()`).
11
+ */
12
+ import assert from 'node:assert/strict'
13
+ import { foldMessages, isStalled, maxSeq, waitForReply } from '../src/core.ts'
14
+
15
+ let passed = 0
16
+ let failed = 0
17
+
18
+ async function test(name, fn) {
19
+ try {
20
+ await fn()
21
+ passed += 1
22
+ console.log('PASS ' + name)
23
+ } catch (error) {
24
+ failed += 1
25
+ console.error('FAIL ' + name)
26
+ console.error(' ' + (error instanceof Error ? error.message : String(error)))
27
+ }
28
+ }
29
+
30
+ // --- synthetic event log -----------------------------------------------------
31
+ const turnStart = (seq, turn = 1) => ({ seq, time: seq, type: 'turn/start', data: { turn } })
32
+ const turnEnd = (seq, turn = 1) => ({ seq, time: seq, type: 'turn/end', data: { turn } })
33
+ const userMsg = (seq, text) => ({ seq, time: seq, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text }] } })
34
+ const assistantText = (seq, text, step = 1) => ({ seq, time: seq, type: 'assistant/message', data: { turn: 1, step, message: { content: [{ type: 'text', text }] } } })
35
+ /** 纯工具调用中间行:无文本(foldMessages 会给出一个没有 text 的 assistant 行)。 */
36
+ const assistantTool = (seq, name = 'bash', step = 2) => ({ seq, time: seq, type: 'assistant/message', data: { turn: 1, step, message: { content: [{ type: 'text', text: '' }], toolCalls: [{ name }] } } })
37
+ /** 带 reasoning 的已完成段落。 */
38
+ const assistantReasoning = (seq, reasoning, text) => ({ seq, time: seq, type: 'assistant/message', data: { turn: 1, step: 1, message: { content: [{ type: 'reasoning', text: reasoning }, { type: 'text', text }] } } })
39
+ /** 假 Session:core.sessionEvents() 会走 snapshotEvents() 分支。 */
40
+ const fakeSession = (events) => ({ snapshotEvents: () => events })
41
+
42
+ // --- Bug 2: stall semantics ---------------------------------------------------
43
+ await test('isStalled: idle sessions are never stalled (issue #1 Bug 2)', () => {
44
+ assert.equal(isStalled('idle', 10_000_000, 60_000), false)
45
+ assert.equal(isStalled('idle', 0, 60_000), false)
46
+ })
47
+
48
+ await test('isStalled: running sessions stall only past the threshold', () => {
49
+ assert.equal(isStalled('running', 10_000_000, 60_000), true)
50
+ assert.equal(isStalled('running', 60_000, 60_000), false) // "exceeds" is strict
51
+ assert.equal(isStalled('running', 60_001, 60_000), true)
52
+ assert.equal(isStalled('running', null, 60_000), false)
53
+ })
54
+
55
+ // --- Bug 1: wait never loses an already-landed reply --------------------------
56
+ await test('wait reply-mode: pre-existing reply at baseline -> stale + text (main symptom)', async () => {
57
+ const events = [turnStart(0), userMsg(1, 'hi'), assistantText(2, 'pong'), turnEnd(3)]
58
+ const result = await waitForReply({ session: fakeSession(events), baselineSeq: 2, timeoutMs: 1 })
59
+ assert.equal(result.message?.text, 'pong')
60
+ assert.equal(result.message?.seq, 2)
61
+ assert.equal(result.stale, true)
62
+ assert.equal(result.timedOut, true)
63
+ assert.equal(result.seq, 2)
64
+ })
65
+
66
+ await test('wait reply-mode: never returns a later text-less step as the reply', async () => {
67
+ // 回复已落地,目标会话随后又跑了工具调用/推理步(更晚但无文本)。
68
+ // 旧实现 message = textReply ?? latest 会把这个无文本行交出去 -> 渲染 "(no text)"。
69
+ const events = [turnStart(0), userMsg(1, 'hi'), assistantText(2, 'pong'), assistantTool(3), turnEnd(4)]
70
+ const result = await waitForReply({ session: fakeSession(events), baselineSeq: maxSeq(events), timeoutMs: 1 })
71
+ assert.equal(result.message?.text, 'pong')
72
+ assert.equal(result.message?.seq, 2)
73
+ assert.equal(result.stale, true)
74
+ assert.equal(result.timedOut, true)
75
+ })
76
+
77
+ await test('wait reply-mode: legacy baseline (last text row) with a newer tool row', async () => {
78
+ // 旧默认 baseline = 最后一条带文本行;更晚的无文本行让它返回无文本行。
79
+ const events = [turnStart(0), userMsg(1, 'hi'), assistantText(2, 'pong'), assistantTool(3)]
80
+ const result = await waitForReply({ session: fakeSession(events), baselineSeq: 2, timeoutMs: 1 })
81
+ assert.equal(result.message?.text, 'pong')
82
+ assert.equal(result.stale, true)
83
+ assert.equal(result.timedOut, true)
84
+ })
85
+
86
+ await test('wait reply-mode: a genuinely new reply returns immediately, stale=false', async () => {
87
+ const events = [turnStart(0), userMsg(1, 'hi'), assistantText(2, 'old')]
88
+ const baseline = maxSeq(events)
89
+ setTimeout(() => { events.push(assistantText(3, 'new')) }, 30)
90
+ const started = Date.now()
91
+ const result = await waitForReply({ session: fakeSession(events), baselineSeq: baseline, timeoutMs: 2_000 })
92
+ assert.equal(result.message?.text, 'new')
93
+ assert.equal(result.stale, false)
94
+ assert.equal(result.timedOut, false)
95
+ assert.ok(Date.now() - started < 1_000, 'should return as soon as the new reply lands')
96
+ })
97
+
98
+ await test('wait segment-mode: pre-existing segment -> stale + segment fields', async () => {
99
+ const events = [turnStart(0), userMsg(1, 'hi'), assistantReasoning(2, 'thinking…', 'answer'), turnEnd(3)]
100
+ const result = await waitForReply({ session: fakeSession(events), baselineSeq: maxSeq(events), timeoutMs: 1, waitForSegment: true })
101
+ assert.equal(result.stale, true)
102
+ assert.equal(result.seq, 2)
103
+ assert.equal(result.message?.text, 'answer')
104
+ assert.equal(result.message?.reasoning, 'thinking…')
105
+ })
106
+
107
+ await test('wait reply-mode: no assistant text at all -> message null, stale=false', async () => {
108
+ const events = [turnStart(0), userMsg(1, 'hi'), assistantTool(2)]
109
+ const result = await waitForReply({ session: fakeSession(events), baselineSeq: maxSeq(events), timeoutMs: 1 })
110
+ assert.equal(result.message, null)
111
+ assert.equal(result.stale, false)
112
+ assert.equal(result.timedOut, true)
113
+ assert.equal(result.seq, maxSeq(events))
114
+ })
115
+
116
+ await test('wait requireTurnEnd: new row without turn/end -> timedOut, stale fallback text', async () => {
117
+ const events = [turnStart(0), userMsg(1, 'hi'), assistantText(2, 'first'), assistantTool(3)]
118
+ const result = await waitForReply({ session: fakeSession(events), baselineSeq: 2, timeoutMs: 1, requireTurnEnd: true })
119
+ assert.equal(result.timedOut, true)
120
+ assert.equal(result.turnEnded, false)
121
+ assert.equal(result.stale, true)
122
+ assert.equal(result.message?.text, 'first')
123
+ })
124
+
125
+ await test('wait requireTurnEnd: nothing new at all -> timedOut (was misreported as false)', async () => {
126
+ const events = [turnStart(0), userMsg(1, 'hi'), assistantText(2, 'first'), assistantTool(3), turnEnd(4)]
127
+ const result = await waitForReply({ session: fakeSession(events), baselineSeq: maxSeq(events), timeoutMs: 1, requireTurnEnd: true })
128
+ assert.equal(result.timedOut, true)
129
+ assert.equal(result.stale, true)
130
+ assert.equal(result.message?.text, 'first')
131
+ })
132
+
133
+ await test('wait sinceSeq=-1 anchor (create path): existing reply counts as new', async () => {
134
+ const events = [turnStart(0), userMsg(1, 'hi'), assistantText(2, 'hello')]
135
+ const started = Date.now()
136
+ const result = await waitForReply({ session: fakeSession(events), baselineSeq: -1, timeoutMs: 2_000 })
137
+ assert.equal(result.message?.text, 'hello')
138
+ assert.equal(result.stale, false)
139
+ assert.equal(result.timedOut, false)
140
+ assert.ok(Date.now() - started < 1_000, 'should return without waiting (nothing to wait for)')
141
+ })
142
+
143
+ // --- sanity -------------------------------------------------------------------
144
+ await test('maxSeq: empty log is -1 (anchor sentinel)', () => {
145
+ assert.equal(maxSeq([]), -1)
146
+ assert.equal(maxSeq(undefined), -1)
147
+ assert.equal(maxSeq([turnStart(0), assistantText(5, 'x')]), 5)
148
+ assert.equal(foldMessages([assistantTool(1)]).length, 1)
149
+ assert.equal(foldMessages([assistantTool(1)])[0].text, undefined)
150
+ })
151
+
152
+ console.log('')
153
+ console.log(String(passed) + ' passed, ' + String(failed) + ' failed')
154
+ if (failed > 0) process.exit(1)
package/src/core.ts CHANGED
@@ -44,6 +44,12 @@ export interface BridgeWaitResult {
44
44
  seq: number
45
45
  turnEnded: boolean
46
46
  timedOut: boolean
47
+ /**
48
+ * true = 返回的是等待开始前就已经存在的输出(整个等待窗口内没有出现新的文本
49
+ * 回复 / 新段落)。此时 message 仍带正文,调用方据此区分"等到了新回复"与
50
+ * "拿到的是既有回复"。不变式:stale === (message !== null && message.seq <= baselineSeq)。
51
+ */
52
+ stale: boolean
47
53
  aborted: boolean
48
54
  waitedMs: number
49
55
  }
@@ -429,8 +435,11 @@ export interface WaitForReplyOptions {
429
435
  * 因此默认不再用 turn/end 作门控;需要完整收尾语义时用 requireTurnEnd:true 显式开启,
430
436
  * 此时才会等到其后出现 turn/end。
431
437
  *
432
- * 返回时 message 为"最新的带文本 assistant 行",否则为最新 assistant 行(可能无文本,
433
- * 如纯工具调用中间态);超时/中止返回已收集内容。
438
+ * 返回的 message:默认是 baseline 之后最新的一条**带文本** assistant 行;整个等待
439
+ * 窗口内没有等到新文本回复时,回落为 baseline 及之前最新的一条带文本回复并置
440
+ * stale:true(绝不把更晚的无文本中间行当回复返回 —— 那正是渲染层打印 "(no text)"
441
+ * 的来源)。segment 模式同理:新段落优先,否则回落既有段落。
442
+ * 超时/中止返回已收集内容;timedOut 表示"本次等待要求的输出未在预算内出现"。
434
443
  */
435
444
  export async function waitForReply(opts: WaitForReplyOptions): Promise<BridgeWaitResult> {
436
445
  const started = Date.now()
@@ -466,23 +475,60 @@ export async function waitForReply(opts: WaitForReplyOptions): Promise<BridgeWai
466
475
  if (Date.now() >= deadline) break
467
476
  await sleep(100)
468
477
  }
478
+ // 等待窗口内是否观察到新输出(即 done 条件是否达成):用于 timedOut / stale 判定。
479
+ const observedNew = waitForSegment ? segment !== null : textReply !== null
480
+ const sawNewRow = latest !== null
481
+ // 零新输出时回落既有内容;回落扫描限定 seq <= baselineSeq,保证 stale 不变式成立,
482
+ // 也不会把"其实算新"的内容标成 stale。
483
+ let stale = false
484
+ if (waitForSegment) {
485
+ if (segment === null) {
486
+ const prev = lastSegmentUpTo(sessionEvents(opts.session), opts.baselineSeq)
487
+ if (prev !== null) { segment = prev; stale = true }
488
+ }
489
+ } else if (textReply === null) {
490
+ const prev = lastTextRowUpTo(foldMessages(sessionEvents(opts.session)), opts.baselineSeq)
491
+ if (prev !== null) { textReply = prev; stale = true }
492
+ }
469
493
  // 段落模式返回该段(把 reasoning 并进返回行,便于“按段落读思维链”);否则返回最新文本行。
470
- let message: BridgeMessageRow | null = waitForSegment && segment !== null ? {
494
+ const message: BridgeMessageRow | null = waitForSegment && segment !== null ? {
471
495
  seq: segment.seq, time: segment.time, role: 'assistant', images: 0,
472
496
  ...(segment.text !== undefined ? { text: segment.text } : {}),
473
497
  ...(segment.reasoning !== undefined ? { reasoning: segment.reasoning } : {}),
474
498
  ...(segment.toolCalls.length > 0 ? { toolCalls: segment.toolCalls } : {}),
475
- } : (textReply ?? latest)
499
+ } : textReply
476
500
  return {
477
501
  message,
478
502
  seq: message === null ? opts.baselineSeq : message.seq,
479
503
  turnEnded,
480
- timedOut: requireTurnEnd ? (latest !== null && !turnEnded) : waitForSegment ? segment === null : textReply === null,
504
+ // done 条件未达成即超时。requireTurnEnd 下"连新行都没出现"同样算超时
505
+ // (旧写法 latest !== null && !turnEnded 会把这种情况误报为未超时)。
506
+ timedOut: requireTurnEnd ? !(sawNewRow && turnEnded) : !observedNew,
507
+ stale,
481
508
  aborted: opts.signal !== undefined && opts.signal.aborted,
482
509
  waitedMs: Date.now() - started,
483
510
  }
484
511
  }
485
512
 
513
+ /** seq 及之前最新一个已完成输出段落(wait 零新输出时回落既有段落)。 */
514
+ function lastSegmentUpTo(events: readonly SessionEvent[], seq: number): BridgeSegment | null {
515
+ const segs = segmentsSince(events)
516
+ for (let i = segs.length - 1; i >= 0; i -= 1) {
517
+ const seg = segs[i]
518
+ if (seg !== undefined && seg.seq <= seq) return seg
519
+ }
520
+ return null
521
+ }
522
+
523
+ /** seq 及之前最新一条带文本的 assistant 行(wait 零新文本回复时回落既有回复)。 */
524
+ function lastTextRowUpTo(rows: readonly BridgeMessageRow[], seq: number): BridgeMessageRow | null {
525
+ for (let i = rows.length - 1; i >= 0; i -= 1) {
526
+ const row = rows[i]
527
+ if (row !== undefined && row.role === 'assistant' && row.text !== undefined && row.seq <= seq) return row
528
+ }
529
+ return null
530
+ }
531
+
486
532
  export interface TargetCwdArgs {
487
533
  workspaceId?: string
488
534
  cwd?: string
@@ -550,6 +596,15 @@ export async function attachSessionToWorkspace(ctx: Context, sessionId: SessionI
550
596
  return workspace.id
551
597
  }
552
598
 
599
+ /**
600
+ * 卡住判定(唯一真源,status 渲染与监控 watchdog 共用):只有 **running** 会话才可能
601
+ * "卡住" —— 空闲/已收尾的会话没有进展是正常状态,不能报 stall。阈值语义为"超过"
602
+ * (严格大于),与工具描述/README 的 "more than stalledMs" 一致。
603
+ */
604
+ export function isStalled(running: 'running' | 'idle', stalledMs: number | null, thresholdMs: number): boolean {
605
+ return running === 'running' && stalledMs !== null && stalledMs > thresholdMs
606
+ }
607
+
553
608
  /** 一次会话监控快照:供"监控线程"判断主任务是否在跑、有无进展、是否卡住。 */
554
609
  export interface BridgeStatusSnapshot {
555
610
  sessionId: string
package/src/monitor.ts CHANGED
@@ -14,6 +14,7 @@ import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
14
14
  import {
15
15
  cancelLiveSession,
16
16
  getLiveAgent,
17
+ isStalled,
17
18
  sendLiveMessage,
18
19
  statusSnapshot,
19
20
  type BridgeStatusSnapshot,
@@ -224,9 +225,8 @@ export class SessionMonitor {
224
225
  }
225
226
 
226
227
  // 2) 卡住判定:仅对 running 会话有意义(距最近事件超过阈值)。
227
- const stalled = snapshot.running === 'running'
228
- && snapshot.stalledMs !== null
229
- && snapshot.stalledMs > (entry.config.stalledMs ?? 60000)
228
+ // 判定走 core.isStalled(唯一真源,与 session_bridge_status [STALLED] 标注一致)。
229
+ const stalled = isStalled(snapshot.running, snapshot.stalledMs, entry.config.stalledMs ?? 60000)
230
230
  if (stalled) {
231
231
  entry.stuckCount += 1
232
232
  entry.lastActionAt = Date.now()
package/src/tools.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  attachSessionToWorkspace,
21
21
  foldMessages,
22
22
  inspectPersistedSession,
23
+ isStalled,
23
24
  maxSeq,
24
25
  resolveTargetCwd,
25
26
  segmentsSince,
@@ -76,6 +77,15 @@ function liveAgents(env: BridgeEnv): LiveAgentLike[] {
76
77
  return agents.list()
77
78
  }
78
79
 
80
+ /** 等待结果里的状态附注行(wait / send / create 三处渲染共用)。 */
81
+ function waitNotes(reply: Record<string, unknown>): string[] {
82
+ const notes: string[] = []
83
+ if (reply.timedOut === true) notes.push('[wait timed out]')
84
+ if (reply.stale === true) notes.push('[stale] pre-existing reply — nothing new arrived during the wait')
85
+ if (reply.aborted === true) notes.push('[wait aborted]')
86
+ return notes
87
+ }
88
+
79
89
  /** 渲染等待结果为 JSON 友好值。 */
80
90
  function renderWait(wait: BridgeWaitResult): Record<string, unknown> {
81
91
  const row = wait.message
@@ -91,6 +101,7 @@ function renderWait(wait: BridgeWaitResult): Record<string, unknown> {
91
101
  seq: wait.seq,
92
102
  turnEnded: wait.turnEnded,
93
103
  timedOut: wait.timedOut,
104
+ ...(wait.stale === true ? { stale: true } : {}),
94
105
  aborted: wait.aborted,
95
106
  waitedMs: wait.waitedMs,
96
107
  }
@@ -154,9 +165,10 @@ function registerCreate(env: BridgeEnv): void {
154
165
  if (typeof v.cwd === 'string') lines.push(`cwd: ${v.cwd}`)
155
166
  if (typeof v.title === 'string') lines.push(`title: ${v.title}`)
156
167
  const reply = v.reply as Record<string, unknown> | undefined
168
+ if (typeof v.sinceSeq === 'number') lines.push(`sinceSeq: ${String(v.sinceSeq)} (pass to session_bridge_wait)`)
157
169
  if (reply !== undefined) {
158
170
  lines.push(`reply: ${String((reply.message as Record<string, unknown> | null)?.text ?? '(no text)')}`)
159
- if (reply.timedOut === true) lines.push('[wait timed out]')
171
+ lines.push(...waitNotes(reply))
160
172
  }
161
173
  return [{ type: 'text' as const, text: lines.join('\n') }]
162
174
  },
@@ -245,8 +257,12 @@ function registerCreate(env: BridgeEnv): void {
245
257
  })
246
258
 
247
259
  let reply: ReturnType<typeof renderWait> | undefined
260
+ // 发送 prompt 之前的日志锚点:异步创建时交还调用方,供之后精确 wait 取回首条回复。
261
+ // 新建会话的 maxSeq 为 -1(尚无事件),所以"未发送"用 undefined 哨兵而不是数值比较。
262
+ let createBaseline: number | undefined
248
263
  if (typeof args.prompt === 'string' && args.prompt.trim() !== '') {
249
264
  const baseline = maxSeq(sessionEvents(agent.session))
265
+ createBaseline = baseline
250
266
  agent.followup(userMessage(args.prompt.trim()))
251
267
  env.registry.touch(sessionId)
252
268
  if (args.waitForReply === true) {
@@ -259,6 +275,7 @@ function registerCreate(env: BridgeEnv): void {
259
275
  cwd: targetCwd,
260
276
  ...(workspaceId === undefined ? {} : { workspaceId }),
261
277
  ...(typeof args.title === 'string' && args.title.trim() !== '' ? { title: args.title.trim() } : {}),
278
+ ...(reply === undefined && createBaseline !== undefined ? { sinceSeq: createBaseline } : {}),
262
279
  ...(reply === undefined ? {} : { reply }),
263
280
  })
264
281
  },
@@ -290,9 +307,10 @@ function registerSend(env: BridgeEnv): void {
290
307
  const v = value as Record<string, unknown>
291
308
  const reply = v.reply as Record<string, unknown> | null | undefined
292
309
  const lines = ['sent to ' + String(v.sessionId)]
310
+ if (typeof v.sinceSeq === 'number') lines.push('sinceSeq: ' + String(v.sinceSeq) + ' (pass to session_bridge_wait)')
293
311
  if (reply !== null && reply !== undefined) {
294
312
  lines.push('reply: ' + String((reply.message as Record<string, unknown> | null)?.text ?? '(no text)'))
295
- if ((reply as Record<string, unknown>).timedOut === true) lines.push('[wait timed out]')
313
+ lines.push(...waitNotes(reply))
296
314
  }
297
315
  return [{ type: 'text' as const, text: lines.join('\n') }]
298
316
  },
@@ -316,7 +334,9 @@ function registerSend(env: BridgeEnv): void {
316
334
  ...(sendWorkspaceId === undefined ? {} : { workspaceId: sendWorkspaceId }),
317
335
  })
318
336
  const reply = await maybeWait(env, agent.session, baseline, args, exec.signal)
319
- return asJson({ accepted: true, sessionId: args.sessionId, ...(reply === undefined ? {} : { reply }) })
337
+ // 未同步等待时把发送前的锚点交还给调用方:之后无论隔多久,用该 sinceSeq
338
+ // session_bridge_wait 都能稳定取回"本次发送之后的回复"(不受调用方延迟影响)。
339
+ return asJson({ accepted: true, sessionId: args.sessionId, ...(reply === undefined ? { sinceSeq: baseline } : { reply }) })
320
340
  },
321
341
  }))
322
342
  }
@@ -413,10 +433,10 @@ interface WaitArgsTool {
413
433
  function registerWait(env: BridgeEnv): void {
414
434
  env.ctx.tools.register(defineTool({
415
435
  name: 'session_bridge_wait',
416
- description: 'Wait for a session next assistant output: blocks (polling the session log) until a NEW assistant output appears after sinceSeq (default: the latest seq at call time). waitFor=reply returns as soon as a new assistant TEXT reply is readable; waitFor=segment returns as soon as any new COMPLETED output segment appears (an assistant/message step — text, reasoning, or tool-call turn), i.e. it does NOT wait for the whole turn, so you can observe the chain-of-thought/output paragraph by paragraph as it is produced. Returns the output summary, or timedOut/aborted when the deadline or caller cancellation ends the wait. Use it to consume output produced asynchronously by another session (e.g. a session you sent a message to, or one working on its own).',
436
+ description: 'Wait for a session next assistant output: blocks (polling the session log) until a NEW assistant output appears after sinceSeq (default: the latest seq at call time). waitFor=reply returns as soon as a new assistant TEXT reply is readable; waitFor=segment returns as soon as any new COMPLETED output segment appears (an assistant/message step — text, reasoning, or tool-call turn), i.e. it does NOT wait for the whole turn, so you can observe the chain-of-thought/output paragraph by paragraph as it is produced. If no new output arrives within the budget, the latest PRE-EXISTING reply/segment is returned with stale=true (so an already-landed reply is never lost as "(no text)"). Returns the output summary, or timedOut/aborted when the deadline or caller cancellation ends the wait. Use it to consume output produced asynchronously by another session (e.g. a session you sent a message to, or one working on its own); to read a reply that may already exist, pass the sinceSeq anchor returned by send/create.',
417
437
  parameters: {
418
438
  sessionId: { type: 'string', required: true, description: 'Session id to wait on.' },
419
- sinceSeq: { type: 'number', description: 'Only replies after this event seq count (default: latest seq at call time).' },
439
+ sinceSeq: { type: 'number', description: 'Only replies after this event seq count (default: latest seq at call time; -1 = count every event, the anchor returned by create for a brand-new session).' },
420
440
  timeoutMs: { type: 'number', description: 'Wait budget in milliseconds (default 180000, max 3600000); timed out waits return the partial result instead of failing.' },
421
441
  requireTurnEnd: { type: 'boolean', description: 'When true, wait for the reply turn/end to settle before returning (default false; false returns as soon as the reply text is readable).' },
422
442
  waitFor: { type: 'string', enum: ['reply', 'segment'], description: 'reply (default) waits for a new assistant TEXT reply; segment waits for any new completed output segment (an assistant/message step, incl. reasoning/tool turns) and returns it immediately, without waiting for the whole turn.' },
@@ -428,10 +448,11 @@ function registerWait(env: BridgeEnv): void {
428
448
  const reply = v.reply as Record<string, unknown> | null | undefined
429
449
  if (reply === null || reply === undefined) return [{ type: 'text' as const, text: 'no reply observed' }]
430
450
  const message = (reply.message as Record<string, unknown> | null)
431
- const lines = ['reply seq ' + String(reply.seq) + ': ' + String(message === null ? '(no text)' : message.text ?? '(no text)')]
451
+ const lines = [message === null
452
+ ? 'no new reply within ' + String(reply.waitedMs ?? '?') + 'ms (no assistant text in this session)'
453
+ : 'reply seq ' + String(reply.seq) + ': ' + String(message.text ?? '(no text)')]
432
454
  if (typeof reply.turnEnded === 'boolean') lines.push('turnEnded: ' + String(reply.turnEnded))
433
- if (reply.timedOut === true) lines.push('[wait timed out]')
434
- if (reply.aborted === true) lines.push('[wait aborted]')
455
+ lines.push(...waitNotes(reply))
435
456
  return [{ type: 'text' as const, text: lines.join('\n') }]
436
457
  },
437
458
  },
@@ -441,34 +462,25 @@ function registerWait(env: BridgeEnv): void {
441
462
  if (agent === undefined) {
442
463
  throw new Error('session ' + JSON.stringify(args.sessionId) + ' is not live — call session_bridge_resume first (waiting requires a live session)')
443
464
  }
444
- // 默认 baseline = 当前最后一条(带文本的)assistant 行的 seq:让 wait 只等待
445
- // 之后新出现的输出,避免把"已存在的输出"当成待等内容,同时不被文本后追加的
446
- // 无文本中间块(推理尾块/工具结果)干扰。segment 模式下以最后一个已完成段落为界。
447
- const waitSegment = args.waitFor === 'segment'
448
- let baseline: number
449
- if (typeof args.sinceSeq === 'number' && Number.isInteger(args.sinceSeq) && args.sinceSeq >= 0) {
450
- baseline = args.sinceSeq
451
- } else if (waitSegment) {
452
- const segs = segmentsSince(sessionEvents(agent.session))
453
- baseline = segs.length === 0 ? -1 : (segs[segs.length - 1]?.seq ?? -1)
454
- } else {
455
- let lastText = -1
456
- for (const row of foldMessages(sessionEvents(agent.session))) {
457
- if (row.text !== undefined) lastText = row.seq
458
- }
459
- baseline = lastText
460
- }
465
+ // 默认 baseline = 调用时刻的日志最大 seq(即工具描述承诺的 "latest seq at call
466
+ // time",两种 waitFor 模式统一):只等待之后新出现的输出。既有回复不会被误当成
467
+ // 新输出,但也绝不会被吞掉 —— waitForReply 在零新输出时会回落它并置 stale:true。
468
+ // 调用方若要在"回复早已落地"之后再精确取回它,应传 send/create 返回的 sinceSeq 锚点。
469
+ const baseline = typeof args.sinceSeq === 'number' && Number.isInteger(args.sinceSeq) && args.sinceSeq >= -1
470
+ ? args.sinceSeq
471
+ : maxSeq(sessionEvents(agent.session))
461
472
  const result = await waitForReply({
462
473
  session: agent.session,
463
474
  baselineSeq: baseline,
464
475
  timeoutMs: clampTimeout(args.timeoutMs),
465
476
  signal: exec.signal,
466
477
  requireTurnEnd: args.requireTurnEnd === true,
467
- ...(waitSegment ? { waitForSegment: true } : {}),
478
+ ...(args.waitFor === 'segment' ? { waitForSegment: true } : {}),
468
479
  })
469
480
  env.registry.touch(args.sessionId)
470
481
  return asJson({
471
482
  sessionId: args.sessionId,
483
+ sinceSeq: baseline,
472
484
  reply: renderWait(result),
473
485
  running: agent.status === 'running',
474
486
  })
@@ -961,7 +973,11 @@ function pruneReasoning(snapshot: BridgeStatusSnapshot, mode: StatusReasoning):
961
973
  return rest
962
974
  }
963
975
 
964
- /** 渲染监控快照为一行摘要:运行态 + openTurn + 卡住/待处理 + 最新回复(+ 思维链预览)。 */
976
+ /**
977
+ * 渲染监控快照为一行摘要:运行态 + openTurn + 卡住/待处理 + 最新回复(+ 思维链预览)。
978
+ * 卡住判定走 core.isStalled(唯一真源):只有 running 会话才可能被标 [STALLED] ——
979
+ * 空闲/已收尾的会话"很久没事件"是正常状态,不是卡住(与监控 watchdog 一致)。
980
+ */
965
981
  function renderStatus(snapshot: BridgeStatusSnapshot, stalledMsThreshold: number): string[] {
966
982
  const lines: string[] = []
967
983
  const runLabel = snapshot.running === 'running' ? 'running' : 'idle'
@@ -969,7 +985,7 @@ function renderStatus(snapshot: BridgeStatusSnapshot, stalledMsThreshold: number
969
985
  if (snapshot.openTurn) lines.push(`openTurn: yes (turn #${snapshot.lastTurn})`)
970
986
  else lines.push(`openTurn: no (last turn #${snapshot.lastTurn})`)
971
987
  if (snapshot.stalledMs !== null) {
972
- const stalled = snapshot.stalledMs >= stalledMsThreshold
988
+ const stalled = isStalled(snapshot.running, snapshot.stalledMs, stalledMsThreshold)
973
989
  lines.push(`lastActivity: now-${snapshot.stalledMs}ms${stalled ? ' [STALLED]' : ''}`)
974
990
  }
975
991
  if (snapshot.pendingWork) lines.push(`pendingWork: ${snapshot.nextTurnCount} turn + ${snapshot.nextStepCount} step`)
@@ -984,10 +1000,10 @@ function renderStatus(snapshot: BridgeStatusSnapshot, stalledMsThreshold: number
984
1000
  function registerStatus(env: BridgeEnv): void {
985
1001
  env.ctx.tools.register(defineTool({
986
1002
  name: 'session_bridge_status',
987
- description: 'Inspect a session\'s live progress for monitoring/scheduling. Returns running/idle, whether a turn is open, last turn number, time since the last event (for stall detection), pending queued work, and the latest text reply. It also surfaces the session\'s chain-of-thought: lastReasoning is the most recent finalized reasoning block, liveReasoning is the in-flight reasoning streamed for the current handled turn (reasoning-delta), and reasoningTail is a compact merged preview. reasoning=none drops all three to keep tokens small. When stalledMsThreshold is given, marks the session as stalled when the time since the last event exceeds it. Pass sessionId of a live session (use session_bridge_find to locate; session_bridge_resume to bring an offline one online). Use this as the "observe" step of a monitor→decide→steer/cancel loop.',
1003
+ description: 'Inspect a session\'s live progress for monitoring/scheduling. Returns running/idle, whether a turn is open, last turn number, time since the last event (for stall detection), pending queued work, and the latest text reply. It also surfaces the session\'s chain-of-thought: lastReasoning is the most recent finalized reasoning block, liveReasoning is the in-flight reasoning streamed for the current handled turn (reasoning-delta), and reasoningTail is a compact merged preview. reasoning=none drops all three to keep tokens small. When stalledMsThreshold is given, marks a RUNNING session as stalled once the time since the last event exceeds it (idle sessions are never flagged — a quiet finished session is not stuck). Pass sessionId of a live session (use session_bridge_find to locate; session_bridge_resume to bring an offline one online). Use this as the "observe" step of a monitor→decide→steer/cancel loop.',
988
1004
  parameters: {
989
1005
  sessionId: { type: 'string', required: true, description: 'Session id to inspect (must be live).' },
990
- stalledMsThreshold: { type: 'number', description: 'Mark the session STALLED when time since the last event exceeds this many ms (default 60000).' },
1006
+ stalledMsThreshold: { type: 'number', description: 'Mark a RUNNING session STALLED when time since the last event exceeds this many ms (default 60000); idle sessions are never marked.' },
991
1007
  recent: { type: 'number', description: 'Number of recent messages to include in the snapshot (default 8, max 20).' },
992
1008
  reasoning: { type: 'string', enum: ['none', 'last', 'live', 'tail'], description: 'Which chain-of-thought fields to include: tail (default) returns lastReasoning/liveReasoning/reasoningTail; last only the finalized reasoning; live only the in-flight reasoning; none drops all reasoning fields.' },
993
1009
  },