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/src/tools.ts ADDED
@@ -0,0 +1,1005 @@
1
+ /**
2
+ * dsh-session-bridge 模型面向工具:create / send / resume / wait / read / find。
3
+ * 全部经插件宿主 ctx 操作真实 DSH 会话(顶层主会话与任意其它会话)。
4
+ */
5
+ import { randomUUID } from 'node:crypto'
6
+ import type { Context } from '@deepseek-ai/cordis'
7
+ import { defineTool } from '@deepseek-ai/dsh-tools'
8
+ import type { JsonValue } from '@deepseek-ai/dsh-util-values'
9
+ import type {} from '@deepseek-ai/dsh-agent-presets'
10
+ import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
11
+ import * as agentApi from '@deepseek-ai/dsh-agent'
12
+ import type {
13
+ BridgeFindItem,
14
+ BridgeMessageRow,
15
+ BridgeStatusSnapshot,
16
+ BridgeWaitResult,
17
+ LiveAgentLike,
18
+ WaitForReplyOptions,
19
+ } from './core.ts'
20
+ import {
21
+ attachSessionToWorkspace,
22
+ foldMessages,
23
+ maxSeq,
24
+ resolveTargetCwd,
25
+ statusSnapshot,
26
+ titleOf,
27
+ userMessage,
28
+ waitForReply,
29
+ workspaceByPath,
30
+ workspaceBySession,
31
+ } from './core.ts'
32
+ import type { BridgeRegistry } from './registry.ts'
33
+ import type { SessionMonitor, MonitorConfig, MonitorEntryState } from './monitor.ts'
34
+
35
+ type SessionIdBrand = { readonly __sessionIdBrand?: never }
36
+
37
+ export interface BridgeEnv {
38
+ ctx: Context
39
+ registry: BridgeRegistry
40
+ monitor: SessionMonitor
41
+ }
42
+
43
+ interface ModelSelectionRef {
44
+ current: { provider?: string; model?: string; reasoningEffort?: string } | undefined
45
+ assembled?: undefined
46
+ }
47
+
48
+ function asJson(value: unknown): Record<string, JsonValue> {
49
+ return value as Record<string, JsonValue>
50
+ }
51
+
52
+ const MAX_WAIT_MS = 3_600_000
53
+
54
+ function clampTimeout(ms: number | undefined, fallback = 180_000): number {
55
+ if (ms === undefined) return fallback
56
+ if (!Number.isFinite(ms) || ms <= 0) throw new Error(`invalid timeoutMs: expected a positive number, got ${JSON.stringify(ms)}`)
57
+ return Math.min(ms, MAX_WAIT_MS)
58
+ }
59
+
60
+ function clampLimit(limit: number | undefined, fallback: number, max: number): number {
61
+ if (limit === undefined) return fallback
62
+ if (!Number.isInteger(limit) || limit < 1) throw new Error(`invalid limit: expected a positive integer, got ${JSON.stringify(limit)}`)
63
+ return Math.min(limit, max)
64
+ }
65
+
66
+ /** 结构取活 agent(ctx.agents 的返回类型按 dsh-agent 的 Agent 形状;这里用结构面)。 */
67
+ function liveAgent(env: BridgeEnv, sessionId: string): LiveAgentLike | undefined {
68
+ const agents = env.ctx.agents as unknown as { get(id: string): LiveAgentLike | undefined }
69
+ return agents.get(sessionId)
70
+ }
71
+
72
+ function liveAgents(env: BridgeEnv): LiveAgentLike[] {
73
+ const agents = env.ctx.agents as unknown as { list(): LiveAgentLike[] }
74
+ return agents.list()
75
+ }
76
+
77
+ /** 渲染等待结果为 JSON 友好值。 */
78
+ function renderWait(wait: BridgeWaitResult): Record<string, unknown> {
79
+ const row = wait.message
80
+ return {
81
+ message: row === null ? null : {
82
+ seq: row.seq,
83
+ role: row.role,
84
+ ...(row.text !== undefined ? { text: row.text } : {}),
85
+ ...(row.reasoning !== undefined ? { reasoning: row.reasoning } : {}),
86
+ images: row.images,
87
+ toolCalls: row.toolCalls ?? [],
88
+ },
89
+ seq: wait.seq,
90
+ turnEnded: wait.turnEnded,
91
+ timedOut: wait.timedOut,
92
+ aborted: wait.aborted,
93
+ waitedMs: wait.waitedMs,
94
+ }
95
+ }
96
+
97
+ /** 等待参数(send/create 共用)。 */
98
+ interface WaitArgs {
99
+ waitForReply?: boolean
100
+ timeoutMs?: number
101
+ }
102
+
103
+ async function maybeWait(env: BridgeEnv, session: { events: readonly SessionEvent[] }, baselineSeq: number, args: WaitArgs, signal: AbortSignal): Promise<ReturnType<typeof renderWait> | undefined> {
104
+ if (args.waitForReply !== true) return undefined
105
+ const result = await waitForReply({
106
+ session: session as WaitForReplyOptions['session'],
107
+ baselineSeq,
108
+ timeoutMs: clampTimeout(args.timeoutMs),
109
+ signal,
110
+ requireTurnEnd: false,
111
+ })
112
+ return renderWait(result)
113
+ }
114
+
115
+ interface CreateArgs {
116
+ workspaceId?: string
117
+ cwd?: string
118
+ title?: string
119
+ prompt?: string
120
+ agentPreset?: string
121
+ provider?: string
122
+ model?: string
123
+ reasoningEffort?: string
124
+ waitForReply?: boolean
125
+ timeoutMs?: number
126
+ }
127
+
128
+ function registerCreate(env: BridgeEnv): void {
129
+ env.ctx.tools.register(defineTool({
130
+ name: 'session_bridge_create',
131
+ description: 'Create a NEW main session (a top-level session that also appears in the DSH session list) and optionally send its first prompt. '
132
+ + 'The session runs in the target workspace: pass workspaceId or cwd to target another workspace (cross-workspace), otherwise the caller\'s current workspace is used. '
133
+ + 'Provider/model default to the current conversation; titles are only a bridge-side label (the real session title is generated automatically). '
134
+ + 'Returns the new session id; with waitForReply=true it blocks until the first assistant reply completes and returns it.',
135
+ parameters: {
136
+ workspaceId: { type: 'string', description: 'Target workspace id (from session_bridge_find). Takes precedence over cwd.' },
137
+ cwd: { type: 'string', description: 'Target working directory for the new session (absolute path or any spelling).' },
138
+ title: { type: 'string', description: 'Optional bridge-side label remembered for find-by-name; does not rename the real session.' },
139
+ prompt: { type: 'string', description: 'Optional first prompt sent right after creation.' },
140
+ agentPreset: { type: 'string', description: 'Optional agent preset id for composition.' },
141
+ provider: { type: 'string', description: 'LLM provider override (default: inherit from the current conversation).' },
142
+ model: { type: 'string', description: 'LLM model override (default: inherit from the current conversation).' },
143
+ reasoningEffort: { type: 'string', description: 'Reasoning effort override for the new session.' },
144
+ waitForReply: { type: 'boolean', description: 'When true and prompt is given, wait for the first assistant reply (default false).' },
145
+ timeoutMs: { type: 'number', description: 'Wait timeout in milliseconds (default 180000, max 3600000).' },
146
+ },
147
+ output: {
148
+ schema: { type: 'object', additionalProperties: true },
149
+ render: (_args, value) => {
150
+ const v = value as Record<string, unknown>
151
+ const lines: string[] = [`created main session ${String(v.sessionId)}`]
152
+ if (typeof v.cwd === 'string') lines.push(`cwd: ${v.cwd}`)
153
+ if (typeof v.title === 'string') lines.push(`title: ${v.title}`)
154
+ const reply = v.reply as Record<string, unknown> | undefined
155
+ if (reply !== undefined) {
156
+ lines.push(`reply: ${String((reply.message as Record<string, unknown> | null)?.text ?? '(no text)')}`)
157
+ if (reply.timedOut === true) lines.push('[wait timed out]')
158
+ }
159
+ return [{ type: 'text' as const, text: lines.join('\n') }]
160
+ },
161
+ },
162
+ async execute(args: CreateArgs, exec) {
163
+ const caller = exec.agent as LiveAgentLike | undefined
164
+ const callerCwd = caller?.session.header.cwd
165
+ const targetCwd = await resolveTargetCwd(env.ctx, callerCwd, args)
166
+ const callerHeader = caller?.session.requestHeader?.()
167
+ const provider = typeof args.provider === 'string' && args.provider !== '' ? args.provider : callerHeader?.config?.provider ?? ''
168
+ const model = typeof args.model === 'string' && args.model !== '' ? args.model : callerHeader?.config?.model ?? ''
169
+ const reasoningEffort = typeof args.reasoningEffort === 'string' && args.reasoningEffort !== '' ? args.reasoningEffort : callerHeader?.config?.reasoningEffort
170
+
171
+ const selection: ModelSelectionRef = {
172
+ current: {
173
+ ...(provider !== '' ? { provider } : {}),
174
+ ...(model !== '' ? { model } : {}),
175
+ ...(reasoningEffort === undefined ? {} : { reasoningEffort }),
176
+ }
177
+ }
178
+ const installModelSelection = (agentApi as unknown as { installModelSelection?: (agentCtx: Context, selection: ModelSelectionRef) => () => void }).installModelSelection
179
+
180
+ const sessionId = `sb-${randomUUID()}`
181
+ const agents = env.ctx.agents as unknown as {
182
+ create(options: { sessionId: string; meta?: { cwd?: string; agentPreset?: string }; agentOptions?: Record<string, unknown>; setup?: (agentCtx: Context) => void }): Promise<{ agent: LiveAgentLike }>
183
+ }
184
+ const handle = await agents.create({
185
+ sessionId,
186
+ meta: {
187
+ cwd: targetCwd,
188
+ ...(typeof args.agentPreset === 'string' && args.agentPreset.trim() !== '' ? { agentPreset: args.agentPreset.trim() } : {}),
189
+ },
190
+ agentOptions: {
191
+ ...(provider !== '' ? { provider } : {}),
192
+ ...(model !== '' ? { model } : {}),
193
+ ...(reasoningEffort === undefined ? {} : { reasoningEffort }),
194
+ },
195
+ setup: (agentCtx: Context) => {
196
+ if (caller !== undefined) {
197
+ const presets = env.ctx.agentPresets as unknown as { composeFrom(agentCtx: Context, parentCtx: Context): string | undefined }
198
+ presets.composeFrom(agentCtx, caller.ctx)
199
+ }
200
+ if (installModelSelection !== undefined) {
201
+ installModelSelection(agentCtx, selection)
202
+ return
203
+ }
204
+ // fallback: rewrite the request config only (model selection split is acceptable)
205
+ const hookable = agentCtx as unknown as { on(name: string, listener: (...args: unknown[]) => unknown): unknown }
206
+ hookable.on('agent/request', async (payload: unknown, next: unknown) => {
207
+ const nxt = next as () => Promise<Record<string, unknown>>
208
+ const resolved = await nxt()
209
+ const sel = selection.current
210
+ if (sel === undefined) return resolved
211
+ const { reasoningEffort: _drop, ...rest } = resolved
212
+ return {
213
+ ...rest,
214
+ ...(sel.provider === undefined ? {} : { provider: sel.provider }),
215
+ ...(sel.model === undefined ? {} : { model: sel.model }),
216
+ ...(sel.reasoningEffort === undefined ? {} : { reasoningEffort: sel.reasoningEffort }),
217
+ }
218
+ })
219
+ },
220
+ })
221
+
222
+ const agent = handle.agent
223
+ // 镜像 session-controller.create 的记账:把新会话计入其 cwd 所属的
224
+ // workspace,否则会话出现在 UI "未分组"(workspace sessionIds 只经
225
+ // attach/bootstrap 两条路径记账)。
226
+ const workspaceId = await attachSessionToWorkspace(env.ctx, sessionId as SessionId, targetCwd) ?? workspaceByPath(env.ctx, targetCwd)
227
+ env.registry.record({
228
+ sessionId,
229
+ ...(typeof args.title === 'string' && args.title.trim() !== '' ? { title: args.title.trim() } : {}),
230
+ cwd: targetCwd,
231
+ ...(workspaceId === undefined ? {} : { workspaceId }),
232
+ ...(provider !== '' ? { provider } : {}),
233
+ ...(model !== '' ? { model } : {}),
234
+ source: 'create',
235
+ })
236
+
237
+ let reply: ReturnType<typeof renderWait> | undefined
238
+ if (typeof args.prompt === 'string' && args.prompt.trim() !== '') {
239
+ const baseline = maxSeq(agent.session.events)
240
+ agent.followup(userMessage(args.prompt.trim()))
241
+ env.registry.touch(sessionId)
242
+ if (args.waitForReply === true) {
243
+ reply = await maybeWait(env, agent.session, baseline, args, exec.signal)
244
+ }
245
+ }
246
+
247
+ return asJson({
248
+ sessionId,
249
+ cwd: targetCwd,
250
+ ...(workspaceId === undefined ? {} : { workspaceId }),
251
+ ...(typeof args.title === 'string' && args.title.trim() !== '' ? { title: args.title.trim() } : {}),
252
+ ...(reply === undefined ? {} : { reply }),
253
+ })
254
+ },
255
+ }))
256
+ }
257
+
258
+ interface SendArgs {
259
+ sessionId: string
260
+ message: string
261
+ mode?: 'queue' | 'steer'
262
+ waitForReply?: boolean
263
+ timeoutMs?: number
264
+ }
265
+
266
+ function registerSend(env: BridgeEnv): void {
267
+ env.ctx.tools.register(defineTool({
268
+ name: 'session_bridge_send',
269
+ description: 'Send a message to any session (by id) and let it start working. If the session is not currently live (offline/persisted), call session_bridge_resume first. mode=queue appends a normal turn; mode=steer injects a step that interrupts the running turn. With waitForReply=true the tool blocks until that session produces its next assistant reply (turn complete), useful for reply-driven orchestration across sessions.',
270
+ parameters: {
271
+ sessionId: { type: 'string', required: true, description: 'Target session id (from session_bridge_find / session_bridge_create).' },
272
+ message: { type: 'string', required: true, description: 'Message text to send as a user turn.' },
273
+ mode: { type: 'string', enum: ['queue', 'steer'], description: 'queue (default) appends a turn; steer interrupts the running turn.' },
274
+ waitForReply: { type: 'boolean', description: 'When true, wait for the next assistant reply after sending (default false).' },
275
+ timeoutMs: { type: 'number', description: 'Wait timeout in milliseconds (default 180000, max 3600000).' },
276
+ },
277
+ output: {
278
+ schema: { type: 'object', additionalProperties: true },
279
+ render: (_args, value) => {
280
+ const v = value as Record<string, unknown>
281
+ const reply = v.reply as Record<string, unknown> | null | undefined
282
+ const lines = ['sent to ' + String(v.sessionId)]
283
+ if (reply !== null && reply !== undefined) {
284
+ lines.push('reply: ' + String((reply.message as Record<string, unknown> | null)?.text ?? '(no text)'))
285
+ if ((reply as Record<string, unknown>).timedOut === true) lines.push('[wait timed out]')
286
+ }
287
+ return [{ type: 'text' as const, text: lines.join('\n') }]
288
+ },
289
+ },
290
+ async execute(args: SendArgs, exec) {
291
+ if (typeof args.sessionId !== 'string' || args.sessionId.trim() === '') throw new Error('invalid sessionId: expected a non-empty string')
292
+ if (typeof args.message !== 'string' || args.message.trim() === '') throw new Error('invalid message: expected a non-empty string')
293
+ const agent = liveAgent(env, args.sessionId)
294
+ if (agent === undefined) {
295
+ throw new Error('session ' + JSON.stringify(args.sessionId) + ' is not live — call session_bridge_resume first to bring it online (or session_bridge_find to locate it)')
296
+ }
297
+ const baseline = maxSeq(agent.session.events)
298
+ if (args.mode === 'steer') agent.steer(userMessage(args.message.trim()))
299
+ else agent.followup(userMessage(args.message.trim()))
300
+ const headerCwd = agent.session.header.cwd
301
+ // 幂等补齐记账:bridge 早期直接 agents.create 未 attach,这里兜底
302
+ // 让存量会话也能归位到其 cwd 所属的 workspace(而非 UI "未分组")。
303
+ const sendWorkspaceId = await attachSessionToWorkspace(env.ctx, args.sessionId as SessionId, headerCwd) ?? workspaceByPath(env.ctx, headerCwd)
304
+ env.registry.touch(args.sessionId, {
305
+ ...(headerCwd === undefined ? {} : { cwd: headerCwd }),
306
+ ...(sendWorkspaceId === undefined ? {} : { workspaceId: sendWorkspaceId }),
307
+ })
308
+ const reply = await maybeWait(env, agent.session, baseline, args, exec.signal)
309
+ return asJson({ accepted: true, sessionId: args.sessionId, ...(reply === undefined ? {} : { reply }) })
310
+ },
311
+ }))
312
+ }
313
+
314
+ interface ResumeArgs {
315
+ sessionId: string
316
+ provider?: string
317
+ model?: string
318
+ reasoningEffort?: string
319
+ }
320
+
321
+ function registerResume(env: BridgeEnv): void {
322
+ env.ctx.tools.register(defineTool({
323
+ name: 'session_bridge_resume',
324
+ description: 'Bring an existing persisted (offline) session back online so session_bridge_send / session_bridge_wait / session_bridge_read can operate on it. Resumes by session id; the session becomes live again (same as opening it). If already live this is a no-op. Provider/model default to the session previous config.',
325
+ parameters: {
326
+ sessionId: { type: 'string', required: true, description: 'Session id to resume (persisted log must exist).' },
327
+ provider: { type: 'string', description: 'Optional provider override.' },
328
+ model: { type: 'string', description: 'Optional model override.' },
329
+ reasoningEffort: { type: 'string', description: 'Optional reasoning effort override.' },
330
+ },
331
+ output: {
332
+ schema: { type: 'object', additionalProperties: true },
333
+ render: (_args, value) => {
334
+ const v = value as Record<string, unknown>
335
+ return [{ type: 'text' as const, text: (v.alreadyLive === true
336
+ ? 'session ' + String(v.sessionId) + ' is already live'
337
+ : 'resumed session ' + String(v.sessionId) + (typeof v.cwd === 'string' ? ' (cwd: ' + v.cwd + ')' : '')) }]
338
+ },
339
+ },
340
+ async execute(args: ResumeArgs) {
341
+ if (typeof args.sessionId !== 'string' || args.sessionId.trim() === '') throw new Error('invalid sessionId: expected a non-empty string')
342
+ const existing = liveAgent(env, args.sessionId)
343
+ if (existing !== undefined) {
344
+ env.registry.touch(args.sessionId)
345
+ return asJson({
346
+ sessionId: args.sessionId,
347
+ alreadyLive: true,
348
+ ...(existing.session.header.cwd === undefined ? {} : { cwd: existing.session.header.cwd }),
349
+ title: titleOf(existing.session.events) ?? null,
350
+ running: existing.status === 'running',
351
+ })
352
+ }
353
+ let headers: readonly { id: string; cwd?: string; createdAt: number }[] = []
354
+ try {
355
+ const persistence = env.ctx.sessionPersistence as unknown as { list(): Promise<readonly { id: string; cwd?: string; createdAt: number }[]> }
356
+ headers = await persistence.list()
357
+ } catch (error) {
358
+ throw new Error('session persistence unavailable: ' + (error instanceof Error ? error.message : String(error)))
359
+ }
360
+ const header = headers.find((h) => h.id === args.sessionId)
361
+ if (header === undefined) {
362
+ throw new Error('session ' + JSON.stringify(args.sessionId) + ' is unknown (no live agent and no persisted log) — use session_bridge_find to locate existing sessions')
363
+ }
364
+ const agents = env.ctx.agents as unknown as {
365
+ resume(options: { resumeSessionId: string; agentOptions?: Record<string, unknown> }): Promise<{ agent: LiveAgentLike }>
366
+ }
367
+ const agentOptions: Record<string, unknown> = {}
368
+ if (typeof args.provider === 'string' && args.provider !== '') agentOptions.provider = args.provider
369
+ if (typeof args.model === 'string' && args.model !== '') agentOptions.model = args.model
370
+ if (typeof args.reasoningEffort === 'string' && args.reasoningEffort !== '') agentOptions.reasoningEffort = args.reasoningEffort
371
+ const handle = await agents.resume({ resumeSessionId: args.sessionId, ...(Object.keys(agentOptions).length > 0 ? { agentOptions } : {}) })
372
+ const headerCwd = header.cwd
373
+ const resumeWorkspaceId = await attachSessionToWorkspace(env.ctx, args.sessionId as SessionId, headerCwd) ?? workspaceByPath(env.ctx, headerCwd)
374
+ env.registry.record({
375
+ sessionId: args.sessionId,
376
+ ...(headerCwd === undefined ? {} : { cwd: headerCwd }),
377
+ ...(resumeWorkspaceId === undefined ? {} : { workspaceId: resumeWorkspaceId }),
378
+ source: 'resume',
379
+ })
380
+ return asJson({
381
+ sessionId: args.sessionId,
382
+ resumed: true,
383
+ ...(headerCwd === undefined ? {} : { cwd: headerCwd }),
384
+ title: titleOf(handle.agent.session.events) ?? null,
385
+ })
386
+ },
387
+ }))
388
+ }
389
+
390
+ interface WaitArgsTool {
391
+ sessionId: string
392
+ sinceSeq?: number
393
+ timeoutMs?: number
394
+ requireTurnEnd?: boolean
395
+ }
396
+
397
+ function registerWait(env: BridgeEnv): void {
398
+ env.ctx.tools.register(defineTool({
399
+ name: 'session_bridge_wait',
400
+ description: 'Wait for a session next assistant reply: blocks (polling the session log) until a NEW assistant text reply appears after sinceSeq (default: the latest seq at call time). Returns the reply summary, or timedOut/aborted when the deadline or caller cancellation ends the wait. Use it to consume a reply produced asynchronously by another session (e.g. a session you sent a message to, or one working on its own).',
401
+ parameters: {
402
+ sessionId: { type: 'string', required: true, description: 'Session id to wait on.' },
403
+ sinceSeq: { type: 'number', description: 'Only replies after this event seq count (default: latest seq at call time).' },
404
+ timeoutMs: { type: 'number', description: 'Wait budget in milliseconds (default 180000, max 3600000); timed out waits return the partial result instead of failing.' },
405
+ 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).' },
406
+ },
407
+ output: {
408
+ schema: { type: 'object', additionalProperties: true },
409
+ render: (_args, value) => {
410
+ const v = value as Record<string, unknown>
411
+ const reply = v.reply as Record<string, unknown> | null | undefined
412
+ if (reply === null || reply === undefined) return [{ type: 'text' as const, text: 'no reply observed' }]
413
+ const message = (reply.message as Record<string, unknown> | null)
414
+ const lines = ['reply seq ' + String(reply.seq) + ': ' + String(message === null ? '(no text)' : message.text ?? '(no text)')]
415
+ if (typeof reply.turnEnded === 'boolean') lines.push('turnEnded: ' + String(reply.turnEnded))
416
+ if (reply.timedOut === true) lines.push('[wait timed out]')
417
+ if (reply.aborted === true) lines.push('[wait aborted]')
418
+ return [{ type: 'text' as const, text: lines.join('\n') }]
419
+ },
420
+ },
421
+ async execute(args: WaitArgsTool, exec) {
422
+ if (typeof args.sessionId !== 'string' || args.sessionId.trim() === '') throw new Error('invalid sessionId: expected a non-empty string')
423
+ const agent = liveAgent(env, args.sessionId)
424
+ if (agent === undefined) {
425
+ throw new Error('session ' + JSON.stringify(args.sessionId) + ' is not live — call session_bridge_resume first (waiting requires a live session)')
426
+ }
427
+ // 默认 baseline = 当前最后一条带文本 assistant 行的 seq:让 wait 只等待
428
+ // 之后新出现的文本回复,避免把"已存在的文本"当成待等回复,同时不被
429
+ // 文本后追加的无文本中间块(推理尾块/工具结果)干扰。
430
+ let baseline: number
431
+ if (typeof args.sinceSeq === 'number' && Number.isInteger(args.sinceSeq) && args.sinceSeq >= 0) {
432
+ baseline = args.sinceSeq
433
+ } else {
434
+ let lastText = -1
435
+ for (const row of foldMessages(agent.session.events)) {
436
+ if (row.text !== undefined) lastText = row.seq
437
+ }
438
+ baseline = lastText
439
+ }
440
+ const result = await waitForReply({
441
+ session: agent.session,
442
+ baselineSeq: baseline,
443
+ timeoutMs: clampTimeout(args.timeoutMs),
444
+ signal: exec.signal,
445
+ requireTurnEnd: args.requireTurnEnd === true,
446
+ })
447
+ env.registry.touch(args.sessionId)
448
+ return asJson({
449
+ sessionId: args.sessionId,
450
+ reply: renderWait(result),
451
+ running: agent.status === 'running',
452
+ })
453
+ },
454
+ }))
455
+ }
456
+
457
+ interface ReadArgs {
458
+ sessionId: string
459
+ sinceSeq?: number
460
+ limit?: number
461
+ role?: 'user' | 'assistant' | 'both'
462
+ includeReasoning?: boolean
463
+ }
464
+
465
+ function registerRead(env: BridgeEnv): void {
466
+ env.ctx.tools.register(defineTool({
467
+ name: 'session_bridge_read',
468
+ description: 'Read messages from any session — live or persisted (offline) — folding its event log into user/assistant text rows. By default returns the latest 20 messages; use limit for more (max 100) and sinceSeq to page forward from an event seq. role filters user/assistant rows; includeReasoning=false drops reasoning blocks.',
469
+ parameters: {
470
+ sessionId: { type: 'string', required: true, description: 'Session id to read (any session, even offline).' },
471
+ sinceSeq: { type: 'number', description: 'Only return messages with seq greater than this value (paging).' },
472
+ limit: { type: 'number', description: 'Number of most recent messages to return (default 20, max 100).' },
473
+ role: { type: 'string', enum: ['user', 'assistant', 'both'], description: 'role filter (default both).' },
474
+ includeReasoning: { type: 'boolean', description: 'Include assistant reasoning blocks (default true).' },
475
+ },
476
+ output: {
477
+ schema: { type: 'object', additionalProperties: true },
478
+ render: (_args, value) => {
479
+ const v = value as Record<string, unknown>
480
+ const messages = (v.messages as Array<Record<string, unknown>> | null) ?? []
481
+ if (messages.length === 0) return [{ type: 'text' as const, text: '(no messages)' }]
482
+ const lines = messages.map((m) => {
483
+ const head = m.role === 'user' ? 'user' : 'assistant'
484
+ const text = typeof m.text === 'string' ? m.text : '(no text)'
485
+ return head + ' #' + String(m.seq) + ': ' + text
486
+ })
487
+ return [{ type: 'text' as const, text: lines.join('\n') }]
488
+ },
489
+ },
490
+ async execute(args: ReadArgs) {
491
+ if (typeof args.sessionId !== 'string' || args.sessionId.trim() === '') throw new Error('invalid sessionId: expected a non-empty string')
492
+ const agent = liveAgent(env, args.sessionId)
493
+ let events: readonly SessionEvent[]
494
+ let live: boolean
495
+ let cwd: string | undefined
496
+ if (agent !== undefined) {
497
+ events = agent.session.events
498
+ live = true
499
+ cwd = agent.session.header.cwd
500
+ } else {
501
+ let inspection: { events: readonly SessionEvent[]; meta: { cwd?: string } }
502
+ try {
503
+ const persistence = env.ctx.sessionPersistence as unknown as { inspect(id: string): Promise<{ events: readonly SessionEvent[]; meta: { cwd?: string } }> }
504
+ inspection = await persistence.inspect(args.sessionId)
505
+ } catch (error) {
506
+ throw new Error('cannot read session ' + JSON.stringify(args.sessionId) + ': ' + (error instanceof Error ? error.message : String(error)))
507
+ }
508
+ events = inspection.events
509
+ live = false
510
+ cwd = inspection.meta.cwd
511
+ }
512
+ let rows = foldMessages(events)
513
+ if (args.role === 'user') rows = rows.filter((r) => r.role === 'user')
514
+ if (args.role === 'assistant') rows = rows.filter((r) => r.role === 'assistant')
515
+ if (args.includeReasoning === false) rows = rows.map((r) => ({ ...r, reasoning: undefined }))
516
+ const sinceSeq = typeof args.sinceSeq === 'number' && Number.isInteger(args.sinceSeq) && args.sinceSeq >= 0 ? args.sinceSeq : undefined
517
+ if (sinceSeq !== undefined) rows = rows.filter((r) => r.seq > sinceSeq)
518
+ const limit = clampLimit(args.limit, 20, 100)
519
+ rows = rows.slice(-limit)
520
+ const workspaceId = workspaceByPath(env.ctx, cwd)
521
+ env.registry.touch(args.sessionId, {
522
+ ...(cwd === undefined ? {} : { cwd }),
523
+ ...(workspaceId === undefined ? {} : { workspaceId }),
524
+ })
525
+ return asJson({
526
+ sessionId: args.sessionId,
527
+ live,
528
+ ...(cwd === undefined ? {} : { cwd }),
529
+ ...(workspaceId === undefined ? {} : { workspaceId }),
530
+ title: titleOf(events) ?? null,
531
+ messages: rows.map((r) => ({
532
+ seq: r.seq,
533
+ time: r.time,
534
+ role: r.role,
535
+ ...(r.text === undefined ? {} : { text: r.text }),
536
+ ...(r.reasoning === undefined ? {} : { reasoning: r.reasoning }),
537
+ images: r.images,
538
+ ...(r.toolCalls === undefined ? {} : { toolCalls: r.toolCalls }),
539
+ })),
540
+ nextSeq: maxSeq(events),
541
+ totalEvents: events.length,
542
+ })
543
+ },
544
+ }))
545
+ }
546
+
547
+ interface FindArgs {
548
+ query?: string
549
+ sessionId?: string
550
+ title?: string
551
+ workspaceId?: string
552
+ cwd?: string
553
+ liveOnly?: boolean
554
+ limit?: number
555
+ }
556
+
557
+ function registerFind(env: BridgeEnv): void {
558
+ env.ctx.tools.register(defineTool({
559
+ name: 'session_bridge_find',
560
+ description: 'Find sessions by name (title), session id, workspace, or directory — across ALL workspaces. Live agents are always current; persisted (offline) sessions come from the durable session store. A plain query matches session id / title / cwd as a case-insensitive substring. Offline titles are resolved lazily from the session log (bounded); bridge-registered titles (from session_bridge_create title) are matched as aliases and used as a fallback. Returns metadata + live/running state, ready to pass to create/send/resume/wait/read.',
561
+ parameters: {
562
+ query: { type: 'string', description: 'Substring matched against session id, title, and cwd (case-insensitive).' },
563
+ sessionId: { type: 'string', description: 'Only sessions whose id contains this substring.' },
564
+ title: { type: 'string', description: 'Only sessions whose title contains this substring (offline titles resolved lazily).' },
565
+ workspaceId: { type: 'string', description: 'Only sessions in this workspace id.' },
566
+ cwd: { type: 'string', description: 'Only sessions whose cwd contains this substring (path or basename).' },
567
+ liveOnly: { type: 'boolean', description: 'When true, only live sessions are returned (default false).' },
568
+ limit: { type: 'number', description: 'Maximum items (default 10, max 50).' },
569
+ },
570
+ output: {
571
+ schema: { type: 'object', additionalProperties: true },
572
+ render: (_args, value) => {
573
+ const v = value as Record<string, unknown>
574
+ const items = (v.items as Array<Record<string, unknown>> | null) ?? []
575
+ if (items.length === 0) return [{ type: 'text' as const, text: '(no sessions found)' }]
576
+ const lines = items.map((item) => {
577
+ const id = String(item.sessionId)
578
+ const title = typeof item.title === 'string' ? item.title : '(untitled)'
579
+ const state = item.live === true ? (item.running === true ? 'running' : 'idle') : 'offline'
580
+ const cwd = typeof item.cwd === 'string' ? item.cwd : ''
581
+ return state + ' ' + id + ' (' + title + ')' + (cwd === '' ? '' : ' @ ' + cwd)
582
+ })
583
+ return [{ type: 'text' as const, text: lines.join('\n') }]
584
+ },
585
+ },
586
+ async execute(args: FindArgs) {
587
+ const limit = clampLimit(args.limit, 10, 50)
588
+ const wsById = workspaceBySession(env.ctx)
589
+ const items: BridgeFindItem[] = []
590
+ for (const agent of liveAgents(env)) {
591
+ const headerCwd = agent.session.header.cwd
592
+ const wsId = wsById.get(agent.id) ?? workspaceByPath(env.ctx, headerCwd)
593
+ items.push({
594
+ sessionId: agent.id,
595
+ title: titleOf(agent.session.events),
596
+ ...(headerCwd === undefined ? {} : { cwd: headerCwd }),
597
+ ...(wsId === undefined ? {} : { workspaceId: wsId }),
598
+ live: true,
599
+ running: agent.status === 'running',
600
+ ...(agent.session.header.parentSession === undefined ? {} : { parentSession: agent.session.header.parentSession }),
601
+ ...(agent.session.header.origin === undefined ? {} : { origin: agent.session.header.origin }),
602
+ createdAt: agent.session.header.createdAt,
603
+ ...(agent.session.header.agentPreset === undefined ? {} : { agentPreset: agent.session.header.agentPreset }),
604
+ })
605
+ }
606
+ if (args.liveOnly !== true) {
607
+ try {
608
+ const persistence = env.ctx.sessionPersistence as unknown as { list(): Promise<readonly { id: string; cwd?: string; parentSession?: string; origin?: 'subagent'; createdAt: number; agentPreset?: string }[]> }
609
+ for (const header of await persistence.list()) {
610
+ if (items.some((item) => item.sessionId === header.id)) continue
611
+ items.push({
612
+ sessionId: header.id,
613
+ ...(header.cwd === undefined ? {} : { cwd: header.cwd }),
614
+ ...(wsById.get(header.id) === undefined ? {} : { workspaceId: wsById.get(header.id) }),
615
+ live: false,
616
+ ...(header.parentSession === undefined ? {} : { parentSession: header.parentSession }),
617
+ ...(header.origin === undefined ? {} : { origin: header.origin }),
618
+ createdAt: header.createdAt,
619
+ ...(header.agentPreset === undefined ? {} : { agentPreset: header.agentPreset }),
620
+ })
621
+ }
622
+ } catch (error) {
623
+ console.warn('[dsh-session-bridge] offline listing failed:', error instanceof Error ? error.message : String(error))
624
+ }
625
+ }
626
+ let registryRecords: Array<{ sessionId: string; title?: string }> = []
627
+ try { registryRecords = await env.registry.all() } catch { registryRecords = [] }
628
+ const bridgeTitles = new Map<string, string>()
629
+ for (const item of items) {
630
+ const rec = registryRecords.find((r) => r.sessionId === item.sessionId)
631
+ if (rec?.title !== undefined) {
632
+ if (item.title === undefined) item.title = rec.title
633
+ bridgeTitles.set(item.sessionId, rec.title)
634
+ }
635
+ }
636
+ const query = typeof args.query === 'string' ? args.query.trim().toLowerCase() : ''
637
+ const idFilter = typeof args.sessionId === 'string' ? args.sessionId.trim().toLowerCase() : ''
638
+ const titleFilter = typeof args.title === 'string' ? args.title.trim().toLowerCase() : ''
639
+ const wsFilter = typeof args.workspaceId === 'string' ? args.workspaceId.trim() : ''
640
+ const cwdFilter = typeof args.cwd === 'string' ? args.cwd.trim().toLowerCase() : ''
641
+ const needsOfflineTitle = titleFilter !== '' || (query !== '' && items.some((item) => !item.live))
642
+ if (needsOfflineTitle) {
643
+ const persistence = env.ctx.sessionPersistence as unknown as { inspect(id: string): Promise<{ events: readonly SessionEvent[] }> }
644
+ let inspected = 0
645
+ for (const item of items) {
646
+ if (inspected >= 30) break
647
+ if (item.live || item.title !== undefined) continue
648
+ try {
649
+ const inspection = await persistence.inspect(item.sessionId)
650
+ item.title = titleOf(inspection.events)
651
+ inspected += 1
652
+ } catch {
653
+ inspected += 1
654
+ }
655
+ }
656
+ }
657
+ const filtered = items.filter((item) => {
658
+ if (idFilter !== '' && !item.sessionId.toLowerCase().includes(idFilter)) return false
659
+ if (titleFilter !== '') {
660
+ const titles = ((item.title ?? '') + ' ' + (bridgeTitles.get(item.sessionId) ?? '')).toLowerCase()
661
+ if (!titles.includes(titleFilter)) return false
662
+ }
663
+ if (wsFilter !== '' && item.workspaceId !== wsFilter) return false
664
+ if (cwdFilter !== '' && !(item.cwd ?? '').toLowerCase().includes(cwdFilter)) return false
665
+ if (query !== '') {
666
+ const haystack = item.sessionId.toLowerCase() + ' ' + (item.title ?? '').toLowerCase() + ' ' + (item.cwd ?? '').toLowerCase() + ' ' + (bridgeTitles.get(item.sessionId) ?? '').toLowerCase()
667
+ if (!haystack.includes(query)) return false
668
+ }
669
+ return true
670
+ })
671
+ filtered.sort((a, b) => {
672
+ if (a.live !== b.live) return a.live ? -1 : 1
673
+ return (b.createdAt ?? 0) - (a.createdAt ?? 0)
674
+ })
675
+ return asJson({
676
+ items: filtered.slice(0, limit).map((item) => ({
677
+ sessionId: item.sessionId,
678
+ ...(item.title === undefined ? {} : { title: item.title }),
679
+ ...(item.cwd === undefined ? {} : { cwd: item.cwd }),
680
+ ...(item.workspaceId === undefined ? {} : { workspaceId: item.workspaceId }),
681
+ live: item.live,
682
+ ...(item.running === undefined ? {} : { running: item.running }),
683
+ ...(item.parentSession === undefined ? {} : { parentSession: item.parentSession }),
684
+ ...(item.origin === undefined ? {} : { origin: item.origin }),
685
+ ...(item.createdAt === undefined ? {} : { createdAt: item.createdAt }),
686
+ ...(item.updatedAt === undefined ? {} : { updatedAt: item.updatedAt }),
687
+ ...(item.agentPreset === undefined ? {} : { agentPreset: item.agentPreset }),
688
+ })),
689
+ total: filtered.length,
690
+ truncated: filtered.length > limit,
691
+ })
692
+ },
693
+ }))
694
+ }
695
+
696
+ export function registerBridgeTools(env: BridgeEnv): void {
697
+ registerCreate(env)
698
+ registerSend(env)
699
+ registerResume(env)
700
+ registerWait(env)
701
+ registerRead(env)
702
+ registerFind(env)
703
+ registerStatus(env)
704
+ registerCancel(env)
705
+ registerMonitor(env)
706
+ registerArchive(env)
707
+ }
708
+
709
+ interface MonitorStartArgs {
710
+ sessionId: string
711
+ intervalMs?: number
712
+ stalledMs?: number
713
+ maxStuckCycles?: number
714
+ doneKeywords?: string[]
715
+ useLlm?: boolean
716
+ onStallSteer?: string
717
+ onOffTrackSteer?: string
718
+ label?: string
719
+ }
720
+
721
+ function renderMonitorState(entry: MonitorEntryState): string[] {
722
+ const lines: string[] = []
723
+ lines.push(`monitoring ${entry.config.sessionId}${entry.config.label === undefined ? '' : ' ("' + entry.config.label + '")'}`)
724
+ lines.push(`interval: ${entry.config.intervalMs}ms | stalled: ${String(entry.config.stalledMs ?? 60000)}ms | maxStuck: ${String(entry.config.maxStuckCycles ?? 3)}`)
725
+ lines.push(`cycles: ${entry.cycles} | stuck: ${entry.stuckCount} | lastAction: ${entry.lastAction}`)
726
+ if (entry.lastNote !== '') lines.push(`note: ${entry.lastNote}`)
727
+ if (entry.done) lines.push('done: yes')
728
+ return lines
729
+ }
730
+
731
+ function registerMonitor(env: BridgeEnv): void {
732
+ env.ctx.tools.register(defineTool({
733
+ name: 'session_bridge_monitor_start',
734
+ description: 'Start a background watchdog on a main session: poll its progress at an interval, and automatically schedule — steer the session when it stalls (or, with useLlm, when it drifts off-track), cancel it when it stays stuck past maxStuckCycles, and stop when a done keyword appears while idle. This is the "monitor worker" that watches a main task thread and corrects/stops it. Uses session_bridge_status-style facts; pass sessionId of a live session. Returns the watchdog state.',
735
+ parameters: {
736
+ sessionId: { type: 'string', required: true, description: 'Target main session id to watch (must be live).' },
737
+ intervalMs: { type: 'number', description: 'Poll interval in ms (default 10000, min 5000).' },
738
+ stalledMs: { type: 'number', description: 'Treat as stalled when no event for this many ms (default 60000).' },
739
+ maxStuckCycles: { type: 'number', description: 'Cancel the session after this many consecutive stall ticks (default 3).' },
740
+ doneKeywords: { type: 'array', items: { type: 'string' }, description: 'Any of these strings in the reply (while idle) marks the task done and stops the watchdog.' },
741
+ useLlm: { type: 'boolean', description: 'When true, use the LLM to also detect off-track (default false, rules only).' },
742
+ onStallSteer: { type: 'string', description: 'Steer text injected on a stall/nudge (default: ask to summarize progress and continue).' },
743
+ onOffTrackSteer: { type: 'string', description: 'Steer text injected when LLM judges the task off-track (default: ask to return to the original goal).' },
744
+ label: { type: 'string', description: 'Optional human label for logs/display.' },
745
+ },
746
+ output: {
747
+ schema: { type: 'object', additionalProperties: true },
748
+ render: (_args, value) => {
749
+ const v = value as Record<string, unknown>
750
+ const entry = v.monitor as MonitorEntryState | null
751
+ if (entry === null || entry === undefined) return [{ type: 'text' as const, text: 'monitor not started' }]
752
+ return [{ type: 'text' as const, text: renderMonitorState(entry).join('\n') }]
753
+ },
754
+ },
755
+ async execute(args: MonitorStartArgs) {
756
+ if (typeof args.sessionId !== 'string' || args.sessionId.trim() === '') throw new Error('invalid sessionId: expected a non-empty string')
757
+ const config: MonitorConfig = {
758
+ sessionId: args.sessionId.trim(),
759
+ intervalMs: typeof args.intervalMs === 'number' && args.intervalMs >= 5000 ? Math.floor(args.intervalMs) : 10000,
760
+ ...(typeof args.stalledMs === 'number' && args.stalledMs > 0 ? { stalledMs: Math.floor(args.stalledMs) } : {}),
761
+ ...(typeof args.maxStuckCycles === 'number' && args.maxStuckCycles >= 1 ? { maxStuckCycles: Math.floor(args.maxStuckCycles) } : {}),
762
+ doneKeywords: Array.isArray(args.doneKeywords) ? args.doneKeywords.filter((k) => typeof k === 'string' && k.trim() !== '') : [],
763
+ ...(args.useLlm === true ? { useLlm: true } : {}),
764
+ ...(typeof args.onStallSteer === 'string' && args.onStallSteer.trim() !== '' ? { onStallSteer: args.onStallSteer.trim() } : {}),
765
+ ...(typeof args.onOffTrackSteer === 'string' && args.onOffTrackSteer.trim() !== '' ? { onOffTrackSteer: args.onOffTrackSteer.trim() } : {}),
766
+ ...(typeof args.label === 'string' && args.label.trim() !== '' ? { label: args.label.trim() } : {}),
767
+ }
768
+ const entry = env.monitor.start(config)
769
+ env.registry.touch(args.sessionId)
770
+ return asJson({ sessionId: args.sessionId, monitor: { ...entry, config: { ...entry.config } } })
771
+ },
772
+ }))
773
+
774
+ env.ctx.tools.register(defineTool({
775
+ name: 'session_bridge_monitor_stop',
776
+ description: 'Stop the background watchdog on a session (keep the session itself running). Returns whether a watchdog was active and stopped.',
777
+ parameters: {
778
+ sessionId: { type: 'string', required: true, description: 'Target session id.' },
779
+ },
780
+ output: {
781
+ schema: { type: 'object', additionalProperties: true },
782
+ render: (_args, value) => {
783
+ const v = value as Record<string, unknown>
784
+ return [{ type: 'text' as const, text: (v.wasActive === true ? 'stopped monitor ' : 'no active monitor ') + String(v.sessionId) }]
785
+ },
786
+ },
787
+ async execute(args: { sessionId: string }) {
788
+ if (typeof args.sessionId !== 'string' || args.sessionId.trim() === '') throw new Error('invalid sessionId: expected a non-empty string')
789
+ const wasActive = env.monitor.stop(args.sessionId.trim())
790
+ return asJson({ sessionId: args.sessionId, wasActive })
791
+ },
792
+ }))
793
+
794
+ env.ctx.tools.register(defineTool({
795
+ name: 'session_bridge_monitor_list',
796
+ description: 'List all active watchdogs started via session_bridge_monitor_start, with their poll interval, stall threshold, stuck count, last action, and done status.',
797
+ parameters: {},
798
+ output: {
799
+ schema: { type: 'object', additionalProperties: true },
800
+ render: (_args, value) => {
801
+ const v = value as Record<string, unknown>
802
+ const entries = (v.monitors as MonitorEntryState[] | null) ?? []
803
+ if (entries.length === 0) return [{ type: 'text' as const, text: '(no active monitors)' }]
804
+ return [{ type: 'text' as const, text: entries.map((e) => renderMonitorState(e).join('\n')).join('\n---\n') }]
805
+ },
806
+ },
807
+ async execute() {
808
+ const monitors = env.monitor.list().map((e) => ({ ...e, config: { ...e.config } }))
809
+ return asJson({ monitors })
810
+ },
811
+ }))
812
+ }
813
+
814
+ interface StatusArgs {
815
+ sessionId: string
816
+ stalledMsThreshold?: number
817
+ recent?: number
818
+ }
819
+
820
+ /** 渲染监控快照为一行摘要:运行态 + openTurn + 卡住/待处理 + 最新回复。 */
821
+ function renderStatus(snapshot: BridgeStatusSnapshot, stalledMsThreshold: number): string[] {
822
+ const lines: string[] = []
823
+ const runLabel = snapshot.running === 'running' ? 'running' : 'idle'
824
+ lines.push(`${runLabel} ${snapshot.sessionId}${snapshot.title === undefined ? '' : ` ("${snapshot.title}")`}`)
825
+ if (snapshot.openTurn) lines.push(`openTurn: yes (turn #${snapshot.lastTurn})`)
826
+ else lines.push(`openTurn: no (last turn #${snapshot.lastTurn})`)
827
+ if (snapshot.stalledMs !== null) {
828
+ const stalled = snapshot.stalledMs >= stalledMsThreshold
829
+ lines.push(`lastActivity: now-${snapshot.stalledMs}ms${stalled ? ' [STALLED]' : ''}`)
830
+ }
831
+ if (snapshot.pendingWork) lines.push(`pendingWork: ${snapshot.nextTurnCount} turn + ${snapshot.nextStepCount} step`)
832
+ if (snapshot.lastAssistantText !== undefined) lines.push(`lastReply: ${snapshot.lastAssistantText}`)
833
+ return lines
834
+ }
835
+
836
+ function registerStatus(env: BridgeEnv): void {
837
+ env.ctx.tools.register(defineTool({
838
+ name: 'session_bridge_status',
839
+ 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. 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.',
840
+ parameters: {
841
+ sessionId: { type: 'string', required: true, description: 'Session id to inspect (must be live).' },
842
+ stalledMsThreshold: { type: 'number', description: 'Mark the session STALLED when time since the last event exceeds this many ms (default 60000).' },
843
+ recent: { type: 'number', description: 'Number of recent messages to include in the snapshot (default 8, max 20).' },
844
+ },
845
+ output: {
846
+ schema: { type: 'object', additionalProperties: true },
847
+ render: (_args, value) => {
848
+ const v = value as Record<string, unknown>
849
+ const snapshot = v.snapshot as BridgeStatusSnapshot | null
850
+ if (snapshot === null || snapshot === undefined) return [{ type: 'text' as const, text: 'session unavailable' }]
851
+ const threshold = typeof v.stalledMsThreshold === 'number' ? v.stalledMsThreshold : 60000
852
+ return [{ type: 'text' as const, text: renderStatus(snapshot, threshold).join('\n') }]
853
+ },
854
+ },
855
+ async execute(args: StatusArgs) {
856
+ if (typeof args.sessionId !== 'string' || args.sessionId.trim() === '') throw new Error('invalid sessionId: expected a non-empty string')
857
+ const agent = liveAgent(env, args.sessionId)
858
+ if (agent === undefined) {
859
+ throw new Error('session ' + JSON.stringify(args.sessionId) + ' is not live — call session_bridge_resume first (status requires a live session)')
860
+ }
861
+ const threshold = typeof args.stalledMsThreshold === 'number' && Number.isFinite(args.stalledMsThreshold) && args.stalledMsThreshold >= 0 ? args.stalledMsThreshold : 60000
862
+ const snapshot = statusSnapshot(env.ctx, agent)
863
+ const shown = typeof args.recent === 'number' && Number.isInteger(args.recent) && args.recent >= 0 ? Math.min(args.recent, 20) : 8
864
+ const envCwd = agent.session.header.cwd
865
+ env.registry.touch(args.sessionId, {
866
+ ...(envCwd === undefined ? {} : { cwd: envCwd }),
867
+ ...(workspaceByPath(env.ctx, envCwd) === undefined ? {} : { workspaceId: workspaceByPath(env.ctx, envCwd) }),
868
+ })
869
+ return asJson({
870
+ sessionId: args.sessionId,
871
+ stalledMsThreshold: threshold,
872
+ snapshot: {
873
+ ...snapshot,
874
+ recent: snapshot.recent.slice(-shown).map((row) => ({ ...row })),
875
+ },
876
+ })
877
+ },
878
+ }))
879
+ }
880
+
881
+ interface CancelArgs {
882
+ sessionId: string
883
+ keepInbox?: boolean
884
+ cause?: string
885
+ }
886
+
887
+ function registerCancel(env: BridgeEnv): void {
888
+ env.ctx.tools.register(defineTool({
889
+ name: 'session_bridge_cancel',
890
+ description: 'Stop a running session: abort the active turn (and if keepInbox is false, also clear queued/steering work). Mirrors the agent cancel primitive. Use this as the "stop" step of a monitor→decide loop when a task has gone wrong, is stuck, or should be terminated. Returns the session state after cancellation. To resume later use session_bridge_resume; to re-queue work use session_bridge_send.',
891
+ parameters: {
892
+ sessionId: { type: 'string', required: true, description: 'Session id to cancel (must be live).' },
893
+ keepInbox: { type: 'boolean', description: 'Preserve queued/steering input instead of clearing it (default false, i.e. clear pending work).' },
894
+ cause: { type: 'string', description: 'Optional stable reason recorded for the cancellation.' },
895
+ },
896
+ output: {
897
+ schema: { type: 'object', additionalProperties: true },
898
+ render: (_args, value) => {
899
+ const v = value as Record<string, unknown>
900
+ return [{ type: 'text' as const, text: 'cancelled ' + String(v.sessionId) + ' (running: ' + String(v.running) + (v.keepInbox === true ? ', inbox kept' : ', inbox cleared') + ')' }]
901
+ },
902
+ },
903
+ async execute(args: CancelArgs) {
904
+ if (typeof args.sessionId !== 'string' || args.sessionId.trim() === '') throw new Error('invalid sessionId: expected a non-empty string')
905
+ const agent = liveAgent(env, args.sessionId)
906
+ if (agent === undefined) {
907
+ throw new Error('session ' + JSON.stringify(args.sessionId) + ' is not live — call session_bridge_resume first (cancel requires a live session)')
908
+ }
909
+ const cause = typeof args.cause === 'string' && args.cause.trim() !== ''
910
+ ? { kind: 'hook', reason: args.cause.trim() }
911
+ : { kind: 'user' }
912
+ agent.cancel(cause, { keepInbox: args.keepInbox === true })
913
+ const snapshot = statusSnapshot(env.ctx, agent)
914
+ const envCwd = agent.session.header.cwd
915
+ env.registry.touch(args.sessionId, {
916
+ ...(envCwd === undefined ? {} : { cwd: envCwd }),
917
+ ...(workspaceByPath(env.ctx, envCwd) === undefined ? {} : { workspaceId: workspaceByPath(env.ctx, envCwd) }),
918
+ })
919
+ return asJson({
920
+ sessionId: args.sessionId,
921
+ running: agent.status,
922
+ keepInbox: args.keepInbox === true,
923
+ snapshot,
924
+ })
925
+ },
926
+ }))
927
+ }
928
+
929
+ interface ArchiveArgs {
930
+ sessionId: string
931
+ }
932
+
933
+ function renderArchived(ids: readonly string[]): string {
934
+ if (ids.length === 0) return '(no archived sessions)'
935
+ return 'archived: ' + ids.join(', ')
936
+ }
937
+
938
+ function registerArchive(env: BridgeEnv): void {
939
+ env.ctx.tools.register(defineTool({
940
+ name: 'session_bridge_archive',
941
+ description: 'Archive one session: add it to the workspace registry\'s global archive set so it is hidden from every grouping surface in the UI (Un/grouped, workspaces) while its session history and workspace position are preserved. Mirrors the workspace controller archiveSession. The session must exist (live or in session persistence). Returns the complete resulting archive set.',
942
+ parameters: {
943
+ sessionId: { type: 'string', required: true, description: 'Session id to archive.' },
944
+ },
945
+ output: {
946
+ schema: { type: 'object', additionalProperties: true },
947
+ render: (_args, value) => {
948
+ const v = value as Record<string, unknown>
949
+ return [{ type: 'text' as const, text: 'archived ' + String(v.sessionId) + '\n' + renderArchived((v.archivedSessionIds as string[] | undefined) ?? []) }]
950
+ },
951
+ },
952
+ async execute(args: ArchiveArgs) {
953
+ if (typeof args.sessionId !== 'string' || args.sessionId.trim() === '') throw new Error('invalid sessionId: expected a non-empty string')
954
+ const sessionId = args.sessionId.trim()
955
+ try {
956
+ await env.ctx.workspaceRegistry.archiveSession(sessionId as SessionId)
957
+ } catch (error) {
958
+ throw new Error('cannot archive session ' + JSON.stringify(sessionId) + ': ' + (error instanceof Error ? error.message : String(error)))
959
+ }
960
+ const archived = env.ctx.workspaceRegistry.archivedSessionIds.map(String)
961
+ return asJson({ sessionId, archivedSessionIds: archived, totalArchived: archived.length })
962
+ },
963
+ }))
964
+
965
+ env.ctx.tools.register(defineTool({
966
+ name: 'session_bridge_archived',
967
+ description: 'List the session ids currently in the workspace registry archive set (hidden from groupings). Optionally resolve titles from the session log. Read-only.',
968
+ parameters: {
969
+ resolveTitles: { type: 'boolean', description: 'When true, resolve each archived session\'s title from its log (default false).' },
970
+ },
971
+ output: {
972
+ schema: { type: 'object', additionalProperties: true },
973
+ render: (_args, value) => {
974
+ const v = value as Record<string, unknown>
975
+ const items = (v.items as Array<{ sessionId: string; title?: string }> | null) ?? []
976
+ if (items.length === 0) return [{ type: 'text' as const, text: '(no archived sessions)' }]
977
+ return [{ type: 'text' as const, text: items.map((i) => i.sessionId + (i.title === undefined ? '' : ' ("' + i.title + '")')).join('\n') }]
978
+ },
979
+ },
980
+ async execute(args: { resolveTitles?: boolean }) {
981
+ const archived = env.ctx.workspaceRegistry.archivedSessionIds.map(String)
982
+ const items: Array<{ sessionId: string; title?: string }> = archived.map((id) => ({ sessionId: id }))
983
+ if (args.resolveTitles === true && items.length > 0) {
984
+ const persistence = env.ctx.sessionPersistence as unknown as { inspect(id: string): Promise<{ events: readonly SessionEvent[] }> }
985
+ const registryTitles = new Map<string, string>()
986
+ try {
987
+ const records = await env.registry.all()
988
+ for (const rec of records) if (rec.title !== undefined) registryTitles.set(rec.sessionId, rec.title)
989
+ } catch { /* best-effort */ }
990
+ for (const item of items) {
991
+ if (registryTitles.has(item.sessionId)) {
992
+ item.title = registryTitles.get(item.sessionId)
993
+ continue
994
+ }
995
+ try {
996
+ const inspection = await persistence.inspect(item.sessionId)
997
+ item.title = titleOf(inspection.events)
998
+ } catch { /* offline title unavailable */ }
999
+ }
1000
+ }
1001
+ return asJson({ items, total: items.length })
1002
+ },
1003
+ }))
1004
+ }
1005
+