dsh-session-bridge 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,87 @@
1
+ {
2
+ "name": "dsh-session-bridge",
3
+ "version": "0.2.0",
4
+ "description": "会话桥(dsh-session-bridge):通过提示词创建新的主会话(顶层 UI 会话)、向任意会话发送消息、等待会话的下一条回复、读取会话消息,并按会话名或 id 跨工作区查找会话;此外支持监控/调度主任务与归档会话。",
5
+ "private": false,
6
+ "type": "module",
7
+ "main": "./lib/index.js",
8
+ "types": "./lib/types/index.d.ts",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/heartmove/dsh-session-bridge.git"
12
+ },
13
+ "homepage": "https://github.com/heartmove/dsh-session-bridge#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/heartmove/dsh-session-bridge/issues"
16
+ },
17
+ "keywords": [
18
+ "dsh",
19
+ "deepseek",
20
+ "plugin",
21
+ "session",
22
+ "session-bridge",
23
+ "bridge"
24
+ ],
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "exports": {
29
+ ".": {
30
+ "types": "./lib/types/index.d.ts",
31
+ "default": "./lib/index.js"
32
+ },
33
+ "./package.json": "./package.json"
34
+ },
35
+ "engines": {
36
+ "node": ">=20"
37
+ },
38
+ "files": [
39
+ "lib/index.js",
40
+ "lib/types/**/*.d.ts",
41
+ "dsh.plugin.json",
42
+ "cordis.patch.yml",
43
+ "scripts",
44
+ "src",
45
+ "README.md",
46
+ "README.zh.md"
47
+ ],
48
+ "dsh": {
49
+ "bundle": {
50
+ "patch": "./cordis.patch.yml"
51
+ }
52
+ },
53
+ "license": "MIT",
54
+ "peerDependencies": {
55
+ "@deepseek-ai/dsh-agent": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0",
56
+ "@deepseek-ai/dsh-agent-presets": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0",
57
+ "@deepseek-ai/dsh-home-paths": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0",
58
+ "@deepseek-ai/dsh-llm": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0",
59
+ "@deepseek-ai/dsh-session": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0",
60
+ "@deepseek-ai/dsh-session-persistence": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0",
61
+ "@deepseek-ai/dsh-tools": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0",
62
+ "@deepseek-ai/dsh-workspace": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0",
63
+ "cordis": "^4.0.0-rc.7"
64
+ },
65
+ "devDependencies": {
66
+ "@deepseek-ai/dsh-agent": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0",
67
+ "@deepseek-ai/dsh-agent-presets": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0",
68
+ "@deepseek-ai/dsh-brand": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0",
69
+ "@deepseek-ai/dsh-home-paths": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0",
70
+ "@deepseek-ai/dsh-llm": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0",
71
+ "@deepseek-ai/dsh-scope": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0",
72
+ "@deepseek-ai/dsh-session": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0",
73
+ "@deepseek-ai/dsh-session-persistence": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0",
74
+ "@deepseek-ai/dsh-tools": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0",
75
+ "@deepseek-ai/dsh-util-values": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0",
76
+ "@deepseek-ai/dsh-workspace": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0",
77
+ "@types/node": "^24.0.0",
78
+ "cordis": "^4.0.0-rc.7",
79
+ "tsdown": "^0.22.14",
80
+ "typescript": "^5.9.3"
81
+ },
82
+ "scripts": {
83
+ "build": "bash scripts/build.sh",
84
+ "build:client": "tsdown",
85
+ "typecheck": "tsc -p tsconfig.json --noEmit"
86
+ }
87
+ }
@@ -0,0 +1,64 @@
1
+ #!/bin/bash
2
+ # Build dsh-session-bridge with the dsh checkout's types.
3
+ # Requires DSH_CHECKOUT pointing at a dsh source checkout. tsc emits
4
+ # the type-check only; the final JS bundle comes from 'npm run build:client'
5
+ # (tsdown), which dev_build_plugin invokes afterwards.
6
+ set -euo pipefail
7
+
8
+ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
9
+ cd "$ROOT"
10
+
11
+ CHECKOUT="${DSH_CHECKOUT:-}"
12
+ if [ -z "$CHECKOUT" ] || [ ! -d "$CHECKOUT/packages" ]; then
13
+ echo "build: cannot locate the dsh checkout (set DSH_CHECKOUT)" >&2
14
+ exit 1
15
+ fi
16
+
17
+ TSC="$CHECKOUT/node_modules/.bin/tsc"
18
+ if [ ! -x "$TSC" ]; then
19
+ echo "build: tsc not found at $TSC" >&2
20
+ exit 1
21
+ fi
22
+
23
+ link_pkg() {
24
+ local target="$CHECKOUT/$2"
25
+ if [ ! -e "$target" ]; then
26
+ echo "build: dependency target missing: $target" >&2
27
+ exit 1
28
+ fi
29
+ node -e "
30
+ const fs = require('fs');
31
+ const path = require('path');
32
+ const link = path.resolve(process.argv[1]);
33
+ const target = path.resolve(process.argv[2]);
34
+ fs.rmSync(link, { recursive: true, force: true });
35
+ fs.mkdirSync(path.dirname(link), { recursive: true });
36
+ fs.symlinkSync(target, link, process.platform === 'win32' ? 'junction' : 'dir');
37
+ " "node_modules/$1" "$target"
38
+ }
39
+
40
+ echo "=== Linking build dependencies (checkout: $CHECKOUT) ==="
41
+ mkdir -p node_modules/@deepseek-ai
42
+ link_pkg cordis vendor/cordis
43
+ link_pkg cosmokit vendor/cosmokit
44
+ link_pkg schemastery vendor/schemastery
45
+ link_pkg @deepseek-ai/schemastery vendor/schemastery
46
+ link_pkg @deepseek-ai/dsh-tools packages/core/tools
47
+ link_pkg @deepseek-ai/dsh-agent packages/core/agent
48
+ link_pkg @deepseek-ai/dsh-session packages/core/session
49
+ link_pkg @deepseek-ai/dsh-session-persistence packages/session/session-persistence
50
+ link_pkg @deepseek-ai/dsh-workspace packages/workspace/workspace
51
+ link_pkg @deepseek-ai/dsh-home-paths packages/util/home-paths
52
+ link_pkg @deepseek-ai/dsh-agent-presets packages/preset/agent-presets
53
+ link_pkg @deepseek-ai/dsh-llm packages/llm/llm
54
+ link_pkg @deepseek-ai/dsh-util-values packages/util/values
55
+ link_pkg @deepseek-ai/dsh-brand packages/util/brand
56
+ link_pkg @deepseek-ai/dsh-scope packages/core/scope
57
+
58
+ echo "=== Type-checking src ==="
59
+ "$TSC" -p tsconfig.json --noEmit || {
60
+ echo "build: type-check failed" >&2
61
+ exit 1
62
+ }
63
+
64
+ echo "=== Build complete (JS bundle comes from 'npm run build:client') ==="
package/src/core.ts ADDED
@@ -0,0 +1,396 @@
1
+ /**
2
+ * dsh-session-bridge core: 会话创建 / 发消息 / 等待回复 / 读取 / 查找。
3
+ * 所有逻辑跑在插件宿主上下文(rootCtx),操作的是 DSH 真实的会话与 agent 注册表。
4
+ */
5
+ import { randomUUID } from 'node:crypto'
6
+ import type { Context } from '@deepseek-ai/cordis'
7
+ import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
8
+ import type {} from '@deepseek-ai/dsh-agent'
9
+ import type {} from '@deepseek-ai/dsh-session-persistence'
10
+ import { WorkspaceId, type Workspace } from '@deepseek-ai/dsh-workspace'
11
+
12
+ /** Live 会话 agent 的运行时形态(dsh-agent 公开类型较薄,用结构类型)。 */
13
+ export interface LiveAgentLike {
14
+ readonly id: string
15
+ readonly status: 'running' | 'idle'
16
+ readonly session: Session
17
+ readonly options?: { provider?: string; model?: string; reasoningEffort?: string }
18
+ followup(message: unknown): void
19
+ steer(message: unknown): void
20
+ cancel(cause?: unknown, options?: { keepInbox?: boolean }): void
21
+ whenIdle?(): Promise<void>
22
+ readonly ctx: Context
23
+ readonly inbox?: {
24
+ hasPending: boolean
25
+ nextTurn?: readonly unknown[]
26
+ nextStep?: readonly unknown[]
27
+ }
28
+ }
29
+
30
+ export interface BridgeMessageRow {
31
+ seq: number
32
+ time: number
33
+ role: 'user' | 'assistant'
34
+ text?: string
35
+ reasoning?: string
36
+ images: number
37
+ toolCalls?: string[]
38
+ }
39
+
40
+ export interface BridgeWaitResult {
41
+ message: BridgeMessageRow | null
42
+ seq: number
43
+ turnEnded: boolean
44
+ timedOut: boolean
45
+ aborted: boolean
46
+ waitedMs: number
47
+ }
48
+
49
+ export interface BridgeFindItem {
50
+ sessionId: string
51
+ title?: string
52
+ cwd?: string
53
+ workspaceId?: string
54
+ live: boolean
55
+ running?: boolean
56
+ parentSession?: string
57
+ origin?: 'subagent'
58
+ createdAt?: number
59
+ updatedAt?: number
60
+ agentPreset?: string
61
+ }
62
+
63
+ /** agent.followup / steer 接受的用户消息值。 */
64
+ export function userMessage(text: string): unknown {
65
+ return {
66
+ id: randomUUID(),
67
+ role: 'user',
68
+ content: [{ type: 'text', text }],
69
+ source: { kind: 'user' },
70
+ }
71
+ }
72
+
73
+ function blockText(content: unknown): string | undefined {
74
+ if (!Array.isArray(content)) return undefined
75
+ const parts: string[] = []
76
+ for (const raw of content) {
77
+ const block = raw as { type?: unknown; text?: unknown } | null
78
+ if (block !== null && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string') {
79
+ if (block.text !== '') parts.push(block.text)
80
+ }
81
+ }
82
+ return parts.length === 0 ? undefined : parts.join('\n')
83
+ }
84
+
85
+ function countImages(content: unknown): number {
86
+ if (!Array.isArray(content)) return 0
87
+ let n = 0
88
+ for (const raw of content) {
89
+ const block = raw as { type?: unknown } | null
90
+ if (block !== null && typeof block === 'object' && block.type === 'image') n += 1
91
+ }
92
+ return n
93
+ }
94
+
95
+ function toolNamesOf(toolCalls: unknown): string[] {
96
+ if (!Array.isArray(toolCalls)) return []
97
+ const names: string[] = []
98
+ for (const raw of toolCalls) {
99
+ const call = raw as { name?: unknown } | null
100
+ if (call !== null && typeof call === 'object' && typeof call.name === 'string' && !names.includes(call.name)) names.push(call.name)
101
+ }
102
+ return names
103
+ }
104
+
105
+ /** 折叠会话事件日志为可读消息行(跳过非 user 来源与空消息)。 */
106
+ export function foldMessages(events: readonly SessionEvent[]): BridgeMessageRow[] {
107
+ const rows: BridgeMessageRow[] = []
108
+ for (const event of events) {
109
+ if (event.type === 'user/message') {
110
+ const source = (event.data as { source?: { kind?: string } }).source
111
+ if (source !== undefined && source.kind !== 'user') continue
112
+ const text = blockText(event.data.content)
113
+ if (text === undefined) continue
114
+ rows.push({ seq: event.seq, time: event.time, role: 'user', text, images: countImages(event.data.content) })
115
+ } else if (event.type === 'assistant/message') {
116
+ const message = event.data.message as { content?: unknown; reasoning?: unknown; toolCalls?: unknown } | null
117
+ const text = message === null ? undefined : blockText(message.content)
118
+ const reasoning = message === null ? undefined : blockText(message.reasoning)
119
+ const images = message === null ? 0 : countImages(message.content)
120
+ const toolCalls = message === null ? [] : toolNamesOf(message.toolCalls)
121
+ if (text === undefined && reasoning === undefined && toolCalls.length === 0) continue
122
+ const row: BridgeMessageRow = { seq: event.seq, time: event.time, role: 'assistant', images }
123
+ if (text !== undefined) row.text = text
124
+ if (reasoning !== undefined) row.reasoning = reasoning
125
+ if (toolCalls.length > 0) row.toolCalls = toolCalls
126
+ rows.push(row)
127
+ }
128
+ }
129
+ return rows
130
+ }
131
+
132
+ /**
133
+ * 会话标题:优先 session/title 事件(last-wins,与 UI 一致);
134
+ * 无标题事件时回落为第一条用户消息(截断 80 字符)。
135
+ */
136
+ export function titleOf(events: readonly SessionEvent[]): string | undefined {
137
+ let title: string | undefined
138
+ for (const event of events) {
139
+ const raw = event as unknown as { type: string; data: { title?: unknown } }
140
+ if (raw.type === 'session/title') {
141
+ const t = raw.data.title
142
+ if (typeof t === 'string' && t !== '') title = t
143
+ }
144
+ }
145
+ if (title !== undefined) return title
146
+ for (const event of events) {
147
+ if (event.type === 'user/message') {
148
+ const source = (event.data as { source?: { kind?: string } }).source
149
+ if (source !== undefined && source.kind !== 'user') continue
150
+ const text = blockText(event.data.content)
151
+ if (text !== undefined) {
152
+ const trimmed = text.replace(/\s+/g, ' ').trim()
153
+ return trimmed.length > 80 ? trimmed.slice(0, 80) + '…' : trimmed
154
+ }
155
+ }
156
+ }
157
+ return undefined
158
+ }
159
+
160
+ /** 日志最大事件序号。 */
161
+ export function maxSeq(events: readonly SessionEvent[]): number {
162
+ let m = -1
163
+ for (const event of events) if (event.seq > m) m = event.seq
164
+ return m
165
+ }
166
+
167
+ function sleep(ms: number): Promise<void> {
168
+ return new Promise((resolve) => setTimeout(resolve, ms))
169
+ }
170
+
171
+ export interface WaitForReplyOptions {
172
+ session: Session
173
+ baselineSeq: number
174
+ timeoutMs: number
175
+ signal?: AbortSignal
176
+ requireTurnEnd?: boolean
177
+ }
178
+
179
+ /**
180
+ * 轮询等待某会话在 baseline 之后出现新的 assistant 回复。
181
+ *
182
+ * 完成条件默认是"出现一条带文本的新 assistant 回复"(即回复可读),而不是
183
+ * 等待 turn/end。实测发现 bridge 通过 agent.followup() 驱动的 turn 在事件流里
184
+ * 不保证产生 turn/end(会一直等满超时),导致"回复早就有了、读取却要等十几二十秒"。
185
+ * 因此默认不再用 turn/end 作门控;需要完整收尾语义时用 requireTurnEnd:true 显式开启,
186
+ * 此时才会等到其后出现 turn/end。
187
+ *
188
+ * 返回时 message 为"最新的带文本 assistant 行",否则为最新 assistant 行(可能无文本,
189
+ * 如纯工具调用中间态);超时/中止返回已收集内容。
190
+ */
191
+ export async function waitForReply(opts: WaitForReplyOptions): Promise<BridgeWaitResult> {
192
+ const started = Date.now()
193
+ const deadline = started + opts.timeoutMs
194
+ const requireTurnEnd = opts.requireTurnEnd ?? false
195
+ let latest: BridgeMessageRow | null = null
196
+ let textReply: BridgeMessageRow | null = null
197
+ let turnEnded = false
198
+ for (;;) {
199
+ if (opts.signal !== undefined && opts.signal.aborted) break
200
+ const events = opts.session.events
201
+ for (const row of foldMessages(events)) {
202
+ if (row.seq <= opts.baselineSeq || row.role !== 'assistant') continue
203
+ if (latest === null || row.seq > latest.seq) latest = row
204
+ if (row.text !== undefined && (textReply === null || row.seq > textReply.seq)) textReply = row
205
+ }
206
+ if (requireTurnEnd && latest !== null) {
207
+ for (const event of events) {
208
+ if (event.seq > latest.seq && event.type === 'turn/end') { turnEnded = true; break }
209
+ }
210
+ }
211
+ const done = requireTurnEnd ? (latest !== null && turnEnded) : textReply !== null
212
+ if (done) break
213
+ if (Date.now() >= deadline) break
214
+ await sleep(100)
215
+ }
216
+ const message = textReply ?? latest
217
+ return {
218
+ message,
219
+ seq: message === null ? opts.baselineSeq : message.seq,
220
+ turnEnded,
221
+ timedOut: requireTurnEnd ? (latest !== null && !turnEnded) : textReply === null,
222
+ aborted: opts.signal !== undefined && opts.signal.aborted,
223
+ waitedMs: Date.now() - started,
224
+ }
225
+ }
226
+
227
+ export interface TargetCwdArgs {
228
+ workspaceId?: string
229
+ cwd?: string
230
+ }
231
+
232
+ /**
233
+ * 解析目标工作目录:workspaceId(注册表)> cwd(绝对路径/任意拼写)> 调用者会话 cwd。
234
+ * 跨工作区由 workspaceId / cwd 任意指定实现。
235
+ */
236
+ export async function resolveTargetCwd(ctx: Context, callerCwd: string | undefined, args: TargetCwdArgs): Promise<string> {
237
+ if (typeof args.workspaceId === 'string' && args.workspaceId !== '') {
238
+ const ws = ctx.workspaceRegistry.get(WorkspaceId(args.workspaceId))
239
+ if (ws === undefined) throw new Error(`workspace not found: ${args.workspaceId}`)
240
+ return ws.path
241
+ }
242
+ if (typeof args.cwd === 'string' && args.cwd.trim() !== '') return args.cwd.trim()
243
+ if (callerCwd !== undefined && callerCwd !== '') return callerCwd
244
+ throw new Error('cannot determine target working directory: pass cwd or workspaceId')
245
+ }
246
+
247
+ /** 会话 id → workspace id(遍历注册表反查,跨全部工作区)。 */
248
+ export function workspaceBySession(ctx: Context): ReadonlyMap<string, string> {
249
+ const map = new Map<string, string>()
250
+ for (const ws of ctx.workspaceRegistry.list()) {
251
+ for (const sessionId of ws.sessionIds) map.set(sessionId, String(ws.id))
252
+ }
253
+ return map
254
+ }
255
+
256
+ /** 会话 cwd → workspace id(按路径匹配;无则 undefined)。 */
257
+ export function workspaceByPath(ctx: Context, cwd: string | undefined): string | undefined {
258
+ if (cwd === undefined) return undefined
259
+ for (const ws of ctx.workspaceRegistry.list()) {
260
+ if (ws.path === cwd) return ws.id
261
+ }
262
+ return undefined
263
+ }
264
+
265
+ /**
266
+ * 将会话计入其 cwd 所属的 workspace(镜像 session-controller 的 create /
267
+ * fork 记账:它们创建代理后都会调用 workspace.attachSession)。没有这一步,
268
+ * bridge 直建的新会话不会出现在任何 workspace 组,而是落到 UI 的"未分组"。
269
+ * cwd 无可校验 workspace 时(目录未登记/无法解析)返回 undefined,会话保持
270
+ * 未分组,与 workspace 的既有语义一致;attach 失败只告警、不阻断会话本身。
271
+ * @returns 会话所属 workspace id,无可归属时 undefined。
272
+ */
273
+ export async function attachSessionToWorkspace(ctx: Context, sessionId: SessionId, cwd: string | undefined): Promise<string | undefined> {
274
+ if (cwd === undefined || cwd === '') return undefined
275
+ let workspace: Workspace | undefined
276
+ try {
277
+ workspace = await ctx.workspaceRegistry.resolveByPath(cwd)
278
+ } catch {
279
+ workspace = undefined
280
+ }
281
+ if (workspace === undefined) return undefined
282
+ try {
283
+ await workspace.attachSession(sessionId)
284
+ } catch (error) {
285
+ console.warn(
286
+ `[dsh-session-bridge] failed to attach session '${sessionId}' to workspace '${workspace.id}': `
287
+ + (error instanceof Error ? error.message : String(error)),
288
+ )
289
+ return undefined
290
+ }
291
+ return workspace.id
292
+ }
293
+
294
+ /** 一次会话监控快照:供"监控线程"判断主任务是否在跑、有无进展、是否卡住。 */
295
+ export interface BridgeStatusSnapshot {
296
+ sessionId: string
297
+ live: boolean
298
+ running: 'running' | 'idle'
299
+ title?: string
300
+ cwd?: string
301
+ workspaceId?: string
302
+ /** 是否有未闭合的 turn(agent 正在处理一轮)。 */
303
+ openTurn: boolean
304
+ /** 最新 turn 编号(0 = 尚未开始第一轮)。 */
305
+ lastTurn: number
306
+ /** 最近一次事件时间戳(无事件为 null);用于卡住检测。 */
307
+ lastActivityAt: number | null
308
+ /** 距最近一次事件已过去多少毫秒(now - lastActivityAt)。 */
309
+ stalledMs: number | null
310
+ /** 是否有待处理输入(inbox 排队中的消息)。 */
311
+ pendingWork: boolean
312
+ nextTurnCount: number
313
+ nextStepCount: number
314
+ /** 最新一条带文本的 assistant 回复。 */
315
+ lastAssistantText?: string
316
+ /** 折叠后的消息总数。 */
317
+ messageCount: number
318
+ /** 最近几条消息(默认 8)。 */
319
+ recent: BridgeMessageRow[]
320
+ }
321
+
322
+ /**
323
+ * 计算一个 live 会话的监控快照(agent 状态 + 事件折叠出的进度/卡住事实)。
324
+ * 纯读,无副作用。用于 `session_bridge_status`。
325
+ */
326
+ export function statusSnapshot(ctx: Context, agent: LiveAgentLike): BridgeStatusSnapshot {
327
+ const events = agent.session.events
328
+ const rows = foldMessages(events)
329
+ let lastActivityAt: number | null = null
330
+ let lastTurn = 0
331
+ let openTurn = false
332
+ for (const event of events) {
333
+ if (event.type === 'turn/start' || event.type === 'turn/end') {
334
+ const turn = (event.data as { turn?: number } | null)?.turn
335
+ if (typeof turn === 'number' && turn > 0) lastTurn = turn
336
+ }
337
+ if (event.type === 'turn/start') openTurn = true
338
+ if (event.type === 'turn/end') openTurn = false
339
+ if (event.time > (lastActivityAt ?? 0)) lastActivityAt = event.time
340
+ }
341
+ const recent = rows.slice(-8)
342
+ let lastAssistantText: string | undefined
343
+ for (let i = rows.length - 1; i >= 0; i -= 1) {
344
+ const row = rows[i]
345
+ if (row !== undefined && row.role === 'assistant' && row.text !== undefined) {
346
+ lastAssistantText = row.text
347
+ break
348
+ }
349
+ }
350
+ const inbox = agent.inbox
351
+ const cwd = agent.session.header.cwd
352
+ return {
353
+ sessionId: agent.id,
354
+ live: true,
355
+ running: agent.status,
356
+ title: titleOf(events),
357
+ ...(cwd === undefined ? {} : { cwd }),
358
+ ...(workspaceByPath(ctx, cwd) === undefined ? {} : { workspaceId: workspaceByPath(ctx, cwd) }),
359
+ openTurn,
360
+ lastTurn,
361
+ lastActivityAt,
362
+ stalledMs: lastActivityAt === null ? null : Date.now() - lastActivityAt,
363
+ pendingWork: inbox?.hasPending ?? false,
364
+ nextTurnCount: inbox?.nextTurn?.length ?? 0,
365
+ nextStepCount: inbox?.nextStep?.length ?? 0,
366
+ ...(lastAssistantText === undefined ? {} : { lastAssistantText }),
367
+ messageCount: rows.length,
368
+ recent,
369
+ }
370
+ }
371
+
372
+ /** 取 live agent(跨工具/监控共用)。不存在返回 undefined。 */
373
+ export function getLiveAgent(ctx: Context, sessionId: string): LiveAgentLike | undefined {
374
+ const agents = ctx.agents as unknown as { get(id: string): LiveAgentLike | undefined }
375
+ return agents.get(sessionId)
376
+ }
377
+
378
+ /** 对 live 会话发送一条用户消息(queue=追加一轮,steer=打断当前步注入)。 */
379
+ export function sendLiveMessage(ctx: Context, sessionId: string, message: string, mode: 'queue' | 'steer'): boolean {
380
+ const agent = getLiveAgent(ctx, sessionId)
381
+ if (agent === undefined) return false
382
+ if (mode === 'steer') agent.steer(userMessage(message))
383
+ else agent.followup(userMessage(message))
384
+ return true
385
+ }
386
+
387
+ /** 中止一个 live 会话的活动 turn(可保留队列输入)。返回是否发起了取消。 */
388
+ export function cancelLiveSession(ctx: Context, sessionId: string, keepInbox: boolean, reason: string | undefined): boolean {
389
+ const agent = getLiveAgent(ctx, sessionId)
390
+ if (agent === undefined) return false
391
+ const cause = typeof reason === 'string' && reason.trim() !== ''
392
+ ? { kind: 'hook', reason: reason.trim() }
393
+ : { kind: 'user' }
394
+ agent.cancel(cause, { keepInbox })
395
+ return true
396
+ }
package/src/index.ts ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * dsh-session-bridge host half: 注册会话桥工具
3
+ * (create / send / resume / wait / read / find / status / cancel / monitor_* ),
4
+ * 让当前 agent 能通过提示词创建主会话、向任意会话发消息、等待 / 读取回复、
5
+ * 跨工作区查找会话,以及作为"监控 worker"对主任务会话做后台守护 —
6
+ * 轮询进度、卡住则 steer 催办/纠偏、卡住过久则 cancel 终止、完成即收尾。
7
+ * 监控守护循环是插件自身的组件(见 monitor.ts),非独立插件。
8
+ */
9
+ import type { Context } from '@deepseek-ai/cordis'
10
+ import { BridgeRegistry } from './registry.ts'
11
+ import { SessionMonitor } from './monitor.ts'
12
+ import { registerBridgeTools, type BridgeEnv } from './tools.ts'
13
+
14
+ /** Plugin identity for cordis rows. */
15
+ export const name = 'dsh-session-bridge'
16
+
17
+ /** Services required before mounting. */
18
+ export const inject = ['sessions', 'agents', 'sessionPersistence', 'workspaceRegistry', 'agentPresets', 'tools', 'timer']
19
+
20
+ /** Host plugin body. */
21
+ export function apply(ctx: Context): void {
22
+ const registry = new BridgeRegistry()
23
+ void registry.load().catch((error: unknown) => {
24
+ console.warn('[dsh-session-bridge] registry load failed:', error instanceof Error ? error.message : String(error))
25
+ })
26
+ const monitor = new SessionMonitor(ctx)
27
+ const env: BridgeEnv = { ctx, registry, monitor }
28
+ registerBridgeTools(env)
29
+ ctx.effect(() => () => monitor.dispose())
30
+ }