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/README.md +219 -0
- package/README.zh.md +183 -0
- package/cordis.patch.yml +8 -0
- package/dsh.plugin.json +27 -0
- package/lib/index.js +14571 -0
- package/package.json +87 -0
- package/scripts/build.sh +64 -0
- package/src/core.ts +396 -0
- package/src/index.ts +30 -0
- package/src/monitor.ts +320 -0
- package/src/registry.ts +122 -0
- package/src/tools.ts +1005 -0
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
|
+
}
|
package/scripts/build.sh
ADDED
|
@@ -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
|
+
}
|