@modesto_ma/dsh-channels 0.2.0 → 0.4.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 +115 -39
- package/docs/access-control-design.md +1 -1
- package/docs/attachment-pipeline-v2.md +318 -0
- package/docs/feasibility-and-design.md +54 -26
- package/docs/feishu-onboarding.md +123 -15
- package/docs/persistence.md +4 -4
- package/docs/refactor-plan.md +7 -6
- package/docs/workspace-isolation-design.md +81 -0
- package/lib/client.js +30 -15
- package/lib/client.js.map +1 -1
- package/lib/index.js +11008 -5132
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/cards/index.ts +1 -0
- package/src/cards/onboarding.ts +23 -0
- package/src/cards/primitives.ts +20 -0
- package/src/cards/streaming.ts +310 -291
- package/src/cleanup.ts +267 -119
- package/src/client/panel/cleanup-card.tsx +4 -2
- package/src/client/panel/workspace-tab.tsx +1 -1
- package/src/config.ts +221 -13
- package/src/domain.ts +141 -3
- package/src/feishu/agent-context.ts +66 -16
- package/src/feishu/inbound-attachments.ts +440 -3
- package/src/feishu/manifest.ts +270 -0
- package/src/feishu/message-read.ts +1333 -0
- package/src/feishu/native-attachments.ts +998 -0
- package/src/feishu/pending-attachments.ts +562 -0
- package/src/feishu/tools.ts +2345 -0
- package/src/feishu-channel.ts +55 -0
- package/src/host/agent.ts +55 -0
- package/src/host/attachments.ts +210 -0
- package/src/host/index.ts +1 -0
- package/src/index.ts +8 -6
- package/src/routing/inbound.ts +730 -81
- package/src/service.ts +536 -50
- package/src/session/isolation.ts +277 -0
- package/src/session/manager.ts +120 -23
- package/src/session/repair.ts +2 -3
- package/src/session/routes.ts +2 -0
- package/src/session-commands.ts +41 -14
- package/src/settings/contract.ts +43 -21
- package/src/settings/overlay.ts +124 -13
- package/src/settings/surface.ts +147 -14
- package/src/streamer.ts +70 -30
- package/src/workspaces.ts +74 -31
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-topic Session placement: which directory each Feishu conversation works
|
|
3
|
+
* in, without growing the host workspace list with every topic.
|
|
4
|
+
*
|
|
5
|
+
* A Session's **header cwd** is fixed at creation and decides two things:
|
|
6
|
+
* its workspace-registry membership (the GUI project it is grouped under) and
|
|
7
|
+
* the sandbox write boundary. The **effective working directory** is a mutable
|
|
8
|
+
* Session projection (`ctx.workingDirectory`), persisted in the Session log
|
|
9
|
+
* and restored on resume; the bash/fs/terminal tools resolve it before every
|
|
10
|
+
* call.
|
|
11
|
+
*
|
|
12
|
+
* This module plans one topic's placement:
|
|
13
|
+
*
|
|
14
|
+
* - `subdir`: `<workspace>/topics/<token>` — a plain directory switched to via
|
|
15
|
+
* `workingDirectory.set`.
|
|
16
|
+
* - `worktree`: a Git worktree created through `ctx.worktrees` (branch
|
|
17
|
+
* `feishu-<token>`), used when the workspace is a Git repository or when the
|
|
18
|
+
* deployment forces it.
|
|
19
|
+
*
|
|
20
|
+
* Both modes keep the Session's header cwd on the workspace root, so the
|
|
21
|
+
* Session stays grouped under the user's project. When the host exposes no
|
|
22
|
+
* `workingDirectory` service (unusual: it ships with `dsh-base`), the Session
|
|
23
|
+
* is created directly in the topic directory instead — file isolation still
|
|
24
|
+
* holds, but the Session can no longer be attached to the workspace.
|
|
25
|
+
*
|
|
26
|
+
* @module dsh-channels/session/isolation
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { randomUUID } from 'node:crypto'
|
|
30
|
+
import { mkdir, stat } from 'node:fs/promises'
|
|
31
|
+
import path from 'node:path'
|
|
32
|
+
import type { Agent, HostContext, Session } from '../dsh-types.ts'
|
|
33
|
+
import type { FeishuConfig } from '../config.ts'
|
|
34
|
+
import { topicDirFor, worktreeBranchFor } from '../workspaces.ts'
|
|
35
|
+
|
|
36
|
+
// ---- Host service slices (structural, resolved through ctx.get) ----------
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The effective-directory service (`@deepseek-ai/dsh-working-directory`,
|
|
40
|
+
* mounted by every base-backed profile). `set` appends a durable
|
|
41
|
+
* `working-directory/change` event and queues a user-context notice.
|
|
42
|
+
*/
|
|
43
|
+
export interface WorkingDirectoryService {
|
|
44
|
+
get(session: Session): string
|
|
45
|
+
set(agent: Agent, path: string, signal?: AbortSignal): Promise<string>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** One created Git worktree checkout (`ctx.worktrees.create` result). */
|
|
49
|
+
export interface CreatedWorktree {
|
|
50
|
+
path: string
|
|
51
|
+
branch: string
|
|
52
|
+
baseCommit: string
|
|
53
|
+
repositoryRoot: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The optional Git-worktree service (`@deepseek-ai/dsh-experimental-worktree`).
|
|
58
|
+
* It is not mounted everywhere, so every use is guarded.
|
|
59
|
+
*/
|
|
60
|
+
export interface WorktreeService {
|
|
61
|
+
create(
|
|
62
|
+
agent: Agent,
|
|
63
|
+
request?: { name?: string; from?: string },
|
|
64
|
+
signal?: AbortSignal,
|
|
65
|
+
): Promise<CreatedWorktree>
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Placement capabilities resolved once per call site. */
|
|
69
|
+
export interface PlacementServices {
|
|
70
|
+
workingDirectory?: WorkingDirectoryService
|
|
71
|
+
worktrees?: WorktreeService
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Resolve the placement capabilities from the host context (all optional). */
|
|
75
|
+
export function placementServices(ctx: HostContext): PlacementServices {
|
|
76
|
+
const get = <T>(name: string): T | undefined => ctx.get?.(name) as T | undefined
|
|
77
|
+
return {
|
|
78
|
+
workingDirectory: get<WorkingDirectoryService>('workingDirectory'),
|
|
79
|
+
worktrees: get<WorktreeService>('worktrees'),
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Isolation mode actually used for one topic. */
|
|
84
|
+
export type TopicMode = 'subdir' | 'worktree'
|
|
85
|
+
|
|
86
|
+
/** What the caller needs before creating the Session. */
|
|
87
|
+
export interface TopicPlan {
|
|
88
|
+
/** Isolation mode chosen for this topic. */
|
|
89
|
+
readonly mode: TopicMode
|
|
90
|
+
/** Directory to pass as `meta.cwd` (the Session header cwd). */
|
|
91
|
+
readonly cwd: string
|
|
92
|
+
/** Topic directory for `subdir` mode (already created). */
|
|
93
|
+
readonly subdir?: string
|
|
94
|
+
/** Whether the deployment can switch the Session's working directory. */
|
|
95
|
+
readonly switchable: boolean
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Where one topic's Session actually works. */
|
|
99
|
+
export interface TopicPlacement {
|
|
100
|
+
/** Effective working directory (`working-directory` projection value). */
|
|
101
|
+
readonly sessionDir: string
|
|
102
|
+
/** Git branch owning `sessionDir` when it is a worktree checkout. */
|
|
103
|
+
readonly worktreeBranch?: string
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Structured logger slice; best-effort warnings never throw. */
|
|
107
|
+
export type PlacementWarn = (message: string, ...args: unknown[]) => void
|
|
108
|
+
|
|
109
|
+
/** Whether the workspace directory is itself a Git working tree. */
|
|
110
|
+
export async function workspaceHasGitRepository(workspacePath: string): Promise<boolean> {
|
|
111
|
+
try {
|
|
112
|
+
const info = await stat(path.join(workspacePath, '.git'))
|
|
113
|
+
return info.isDirectory() || info.isFile()
|
|
114
|
+
} catch {
|
|
115
|
+
return false
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Pick the isolation mode for one topic. `auto` upgrades to a worktree only
|
|
121
|
+
* when both the capability and a Git repository are present; an explicit
|
|
122
|
+
* `worktree` preference still falls back (with a warning) when unavailable.
|
|
123
|
+
*/
|
|
124
|
+
async function chooseTopicMode(
|
|
125
|
+
services: PlacementServices,
|
|
126
|
+
config: FeishuConfig,
|
|
127
|
+
workspacePath: string,
|
|
128
|
+
warn: PlacementWarn,
|
|
129
|
+
): Promise<TopicMode> {
|
|
130
|
+
const requested = config.topicIsolation
|
|
131
|
+
if (requested === 'subdir') return 'subdir'
|
|
132
|
+
const capable = services.workingDirectory !== undefined && services.worktrees !== undefined
|
|
133
|
+
if (!capable) {
|
|
134
|
+
if (requested === 'worktree') {
|
|
135
|
+
warn('dsh-channels: topicIsolation=worktree needs the workingDirectory and worktrees host services; using a plain topic directory')
|
|
136
|
+
}
|
|
137
|
+
return 'subdir'
|
|
138
|
+
}
|
|
139
|
+
if (requested === 'worktree') return 'worktree'
|
|
140
|
+
return await workspaceHasGitRepository(workspacePath) ? 'worktree' : 'subdir'
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Decide how a new Session is placed and materialize everything that must
|
|
145
|
+
* exist before the Session is created.
|
|
146
|
+
*
|
|
147
|
+
* The returned `cwd` is the header cwd: the workspace root when the runtime
|
|
148
|
+
* can switch directories (so the Session is grouped under the user's project),
|
|
149
|
+
* otherwise the topic directory itself.
|
|
150
|
+
*/
|
|
151
|
+
export async function planTopic(
|
|
152
|
+
services: PlacementServices,
|
|
153
|
+
config: FeishuConfig,
|
|
154
|
+
workspacePath: string,
|
|
155
|
+
topicToken: string,
|
|
156
|
+
warn: PlacementWarn,
|
|
157
|
+
): Promise<TopicPlan> {
|
|
158
|
+
const mode = await chooseTopicMode(services, config, workspacePath, warn)
|
|
159
|
+
if (services.workingDirectory !== undefined) {
|
|
160
|
+
const subdir = mode === 'subdir' ? topicDirFor(workspacePath, topicToken) : undefined
|
|
161
|
+
if (subdir !== undefined) await mkdir(subdir, { recursive: true })
|
|
162
|
+
return {
|
|
163
|
+
mode,
|
|
164
|
+
cwd: workspacePath,
|
|
165
|
+
...(subdir === undefined ? {} : { subdir }),
|
|
166
|
+
switchable: true,
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// No effective-directory service: create the Session inside the topic
|
|
170
|
+
// directory itself. Isolation is preserved; workspace membership is lost.
|
|
171
|
+
const subdir = topicDirFor(workspacePath, topicToken)
|
|
172
|
+
await mkdir(subdir, { recursive: true })
|
|
173
|
+
return { mode: 'subdir', cwd: subdir, subdir, switchable: false }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Enter the planned topic directory once the Session (and its Agent) exists. */
|
|
177
|
+
export async function enterTopic(
|
|
178
|
+
services: PlacementServices,
|
|
179
|
+
agent: Agent,
|
|
180
|
+
plan: TopicPlan,
|
|
181
|
+
workspacePath: string,
|
|
182
|
+
topicToken: string,
|
|
183
|
+
warn: PlacementWarn,
|
|
184
|
+
): Promise<TopicPlacement> {
|
|
185
|
+
if (plan.mode === 'worktree' && services.worktrees !== undefined) {
|
|
186
|
+
try {
|
|
187
|
+
const created = await services.worktrees.create(agent, { name: worktreeBranchFor(topicToken) })
|
|
188
|
+
return { sessionDir: created.path, worktreeBranch: created.branch }
|
|
189
|
+
} catch (error) {
|
|
190
|
+
warn(
|
|
191
|
+
'dsh-channels: creating a worktree for topic %s failed, using a plain topic directory instead: %s',
|
|
192
|
+
topicToken, String(error),
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const dir = plan.subdir ?? topicDirFor(workspacePath, topicToken)
|
|
197
|
+
await mkdir(dir, { recursive: true })
|
|
198
|
+
if (plan.switchable) await alignWorkingDirectory(services, agent, dir, warn)
|
|
199
|
+
return { sessionDir: dir }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Align one live Session with its recorded topic directory: make sure the
|
|
204
|
+
* directory exists, recreate it (or a fresh worktree) when it disappeared,
|
|
205
|
+
* and restore the effective working directory the route expects.
|
|
206
|
+
*
|
|
207
|
+
* The returned placement may carry a new directory/branch after recovery; the
|
|
208
|
+
* caller persists it back into the route record.
|
|
209
|
+
*/
|
|
210
|
+
export async function restoreTopicDir(
|
|
211
|
+
services: PlacementServices,
|
|
212
|
+
agent: Agent,
|
|
213
|
+
input: {
|
|
214
|
+
readonly workspacePath: string
|
|
215
|
+
readonly sessionDir: string
|
|
216
|
+
readonly worktreeBranch?: string
|
|
217
|
+
},
|
|
218
|
+
warn: PlacementWarn,
|
|
219
|
+
): Promise<TopicPlacement> {
|
|
220
|
+
const { sessionDir, worktreeBranch } = input
|
|
221
|
+
if (await isDirectory(sessionDir)) {
|
|
222
|
+
await alignWorkingDirectory(services, agent, sessionDir, warn)
|
|
223
|
+
return worktreeBranch === undefined ? { sessionDir } : { sessionDir, worktreeBranch }
|
|
224
|
+
}
|
|
225
|
+
// The directory is gone (manual deletion, external cleanup, lost host):
|
|
226
|
+
// give the Session a fresh directory so it never silently falls back to the
|
|
227
|
+
// shared workspace root.
|
|
228
|
+
if (worktreeBranch !== undefined
|
|
229
|
+
&& services.worktrees !== undefined
|
|
230
|
+
&& services.workingDirectory !== undefined) {
|
|
231
|
+
try {
|
|
232
|
+
const created = await services.worktrees.create(agent, {
|
|
233
|
+
name: `${worktreeBranch}-r${randomUUID().slice(0, 8)}`,
|
|
234
|
+
})
|
|
235
|
+
return { sessionDir: created.path, worktreeBranch: created.branch }
|
|
236
|
+
} catch (error) {
|
|
237
|
+
warn(
|
|
238
|
+
'dsh-channels: recreating the worktree for session %s failed, using a plain directory: %s',
|
|
239
|
+
agent.id, String(error),
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
await mkdir(sessionDir, { recursive: true })
|
|
244
|
+
await alignWorkingDirectory(services, agent, sessionDir, warn)
|
|
245
|
+
return { sessionDir }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Best-effort check used only for placement decisions, never for security. */
|
|
249
|
+
async function isDirectory(dir: string): Promise<boolean> {
|
|
250
|
+
try {
|
|
251
|
+
return (await stat(dir)).isDirectory()
|
|
252
|
+
} catch {
|
|
253
|
+
return false
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Point the Session's effective working directory at `dir`. */
|
|
258
|
+
async function alignWorkingDirectory(
|
|
259
|
+
services: PlacementServices,
|
|
260
|
+
agent: Agent,
|
|
261
|
+
dir: string,
|
|
262
|
+
warn: PlacementWarn,
|
|
263
|
+
): Promise<void> {
|
|
264
|
+
const workingDirectory = services.workingDirectory
|
|
265
|
+
if (workingDirectory === undefined) return
|
|
266
|
+
try {
|
|
267
|
+
if (workingDirectory.get(agent.session) === dir) return
|
|
268
|
+
await workingDirectory.set(agent, dir)
|
|
269
|
+
} catch (error) {
|
|
270
|
+
// The durable Session keeps its previous directory; the caller cannot do
|
|
271
|
+
// better than warning because the turn is still deliverable.
|
|
272
|
+
warn(
|
|
273
|
+
'dsh-channels: switching session %s to its topic directory failed: %s',
|
|
274
|
+
agent.id, String(error),
|
|
275
|
+
)
|
|
276
|
+
}
|
|
277
|
+
}
|
package/src/session/manager.ts
CHANGED
|
@@ -25,12 +25,15 @@ import type {
|
|
|
25
25
|
} from '../dsh-types.ts'
|
|
26
26
|
import type { FeishuConfig } from '../config.ts'
|
|
27
27
|
import { mountChannelCommands } from '../commands.ts'
|
|
28
|
-
import { appendSessionHistory } from '../domain.ts'
|
|
28
|
+
import { appendSessionHistory, sessionDirOf } from '../domain.ts'
|
|
29
29
|
import type { RouteRecord } from '../domain.ts'
|
|
30
30
|
import { mountChannelGuidance } from '../feishu/agent-context.ts'
|
|
31
31
|
import { attachmentsDir } from '../feishu/inbound-attachments.ts'
|
|
32
|
+
import { effectiveAttachmentMode, resolveAttachmentService } from '../feishu/native-attachments.ts'
|
|
32
33
|
import { installRequestPolicy } from './request-policy.ts'
|
|
33
34
|
import type { RequestPolicyController } from './request-policy.ts'
|
|
35
|
+
import { placementServices, restoreTopicDir } from './isolation.ts'
|
|
36
|
+
import type { PlacementServices, TopicPlacement } from './isolation.ts'
|
|
34
37
|
import { workspaceTitleFor } from '../workspaces.ts'
|
|
35
38
|
|
|
36
39
|
/** One live agent plus this plugin's ownership handle. */
|
|
@@ -63,6 +66,14 @@ export interface SessionManagerDeps {
|
|
|
63
66
|
started(): boolean
|
|
64
67
|
/** Drop streamer state for a session whose owner handle is being released. */
|
|
65
68
|
detachStream(sessionId: string): void
|
|
69
|
+
/**
|
|
70
|
+
* Register this channel's model tools inside one Agent scope. Called from
|
|
71
|
+
* the agent-preset `setup` after the preset is mounted (tools live inside
|
|
72
|
+
* presets in web-profile deployments), and once more for a BORROWED live
|
|
73
|
+
* agent as a best-effort race guard (D7). Optional: unit tests that do not
|
|
74
|
+
* compose model tools omit it.
|
|
75
|
+
*/
|
|
76
|
+
mountTools?(agentCtx: AgentContext, sessionId: string): Promise<void> | void
|
|
66
77
|
warn(message: string, ...args: unknown[]): void
|
|
67
78
|
}
|
|
68
79
|
|
|
@@ -80,6 +91,8 @@ export class SessionManager {
|
|
|
80
91
|
private readonly live = new Map<string, LiveAgent>()
|
|
81
92
|
private readonly pendingResume = new Map<string, Promise<LiveAgent>>()
|
|
82
93
|
private readonly policies = new WeakMap<AgentContext, RequestPolicyController>()
|
|
94
|
+
/** Agent scopes this plugin already attempted to mount model tools into. */
|
|
95
|
+
private readonly toolMounts = new WeakSet<AgentContext>()
|
|
83
96
|
|
|
84
97
|
constructor(private readonly deps: SessionManagerDeps) {}
|
|
85
98
|
|
|
@@ -105,14 +118,16 @@ export class SessionManager {
|
|
|
105
118
|
|
|
106
119
|
/**
|
|
107
120
|
* Create the root agent for one freshly routed session and register it live.
|
|
108
|
-
*
|
|
121
|
+
* `cwd` is the Session header cwd (the user workspace when the deployment can
|
|
122
|
+
* switch directories). The caller owns placement, the workspace attachment,
|
|
123
|
+
* the attachment guidance, and the failure card.
|
|
109
124
|
*/
|
|
110
125
|
async createSession(
|
|
111
126
|
sessionId: SessionId,
|
|
112
127
|
cwd: string,
|
|
113
128
|
agentOptions: Record<string, unknown> = this.agentOptions(),
|
|
114
129
|
): Promise<AgentHandle> {
|
|
115
|
-
const composition = await this.presetComposition(undefined, true,
|
|
130
|
+
const composition = await this.presetComposition(undefined, true, sessionId)
|
|
116
131
|
const handle = await this.deps.ctx.agents.create({
|
|
117
132
|
sessionId,
|
|
118
133
|
meta: {
|
|
@@ -127,6 +142,40 @@ export class SessionManager {
|
|
|
127
142
|
return handle
|
|
128
143
|
}
|
|
129
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Register the attachment guidance for one Session. Called after the
|
|
147
|
+
* effective working directory is known (a worktree checkout path is only
|
|
148
|
+
* returned once the worktree exists), so the archive guidance can name the
|
|
149
|
+
* absolute directory. The guidance flavor follows the *effective* attachment
|
|
150
|
+
* backend: an explicitly configured archive deployment, or a native
|
|
151
|
+
* deployment whose host does not mount `ctx.attachments`, keeps the legacy
|
|
152
|
+
* workspace-directory instructions. Best-effort: a missing system-prompt
|
|
153
|
+
* service (headless) is a no-op and a late registration never blocks the turn.
|
|
154
|
+
*/
|
|
155
|
+
async mountGuidance(agent: Agent, directory: string): Promise<void> {
|
|
156
|
+
// With inbound attachments disabled the Agent has no attachment workflow to
|
|
157
|
+
// discover (and the legacy guidance would wrongly claim files are archived).
|
|
158
|
+
if (!this.deps.config.acceptAttachments) return
|
|
159
|
+
const mode = effectiveAttachmentMode(
|
|
160
|
+
this.deps.config.attachmentStore,
|
|
161
|
+
resolveAttachmentService(this.deps.ctx),
|
|
162
|
+
this.deps.config.feishuTools.attachmentFetch,
|
|
163
|
+
this.deps.config.acceptAttachments,
|
|
164
|
+
)
|
|
165
|
+
try {
|
|
166
|
+
await mountChannelGuidance(
|
|
167
|
+
agent.ctx,
|
|
168
|
+
mode,
|
|
169
|
+
mode === 'archive' ? attachmentsDir(directory) : undefined,
|
|
170
|
+
)
|
|
171
|
+
} catch (error) {
|
|
172
|
+
this.deps.warn(
|
|
173
|
+
'dsh-channels: mounting attachment guidance for session %s failed: %s',
|
|
174
|
+
agent.id, String(error),
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
130
179
|
/**
|
|
131
180
|
* Live agent for a route: this plugin's own → a foreign live agent (borrow)
|
|
132
181
|
* → a serialized cold resume.
|
|
@@ -144,7 +193,7 @@ export class SessionManager {
|
|
|
144
193
|
if (own !== undefined) return own
|
|
145
194
|
|
|
146
195
|
const foreign = this.deps.ctx.agents.get(sessionId as SessionId)
|
|
147
|
-
if (foreign !== undefined) return this.borrow(sessionId, foreign)
|
|
196
|
+
if (foreign !== undefined) return await this.borrow(sessionId, foreign)
|
|
148
197
|
|
|
149
198
|
let pending = this.pendingResume.get(sessionId)
|
|
150
199
|
if (pending === undefined) {
|
|
@@ -157,9 +206,14 @@ export class SessionManager {
|
|
|
157
206
|
}
|
|
158
207
|
|
|
159
208
|
/** Borrow one foreign live agent and mount this channel's scoped policy. */
|
|
160
|
-
private borrow(sessionId: string, agent: Agent): LiveAgent {
|
|
209
|
+
private async borrow(sessionId: string, agent: Agent): Promise<LiveAgent> {
|
|
161
210
|
const borrowed = borrowLiveAgent(agent, this.mountRequestPolicy(agent.ctx, agent.session))
|
|
162
211
|
this.live.set(sessionId, borrowed)
|
|
212
|
+
// Best-effort tool backfill (D7): the foreign owner may have composed the
|
|
213
|
+
// session without this plugin's setup (web UI resolver, previous plugin
|
|
214
|
+
// instance). mountToolsFor swallows failures, so the borrow itself never
|
|
215
|
+
// fails; awaiting it makes the tool available before the first turn.
|
|
216
|
+
await this.mountToolsFor(agent.ctx, sessionId)
|
|
163
217
|
return borrowed
|
|
164
218
|
}
|
|
165
219
|
|
|
@@ -171,9 +225,7 @@ export class SessionManager {
|
|
|
171
225
|
// (config override or roster default). A session persisted before the
|
|
172
226
|
// preset fix has no preset recorded on its header; re-resolving here is
|
|
173
227
|
// what repairs it on the first resume after a restart.
|
|
174
|
-
const composition = await this.presetComposition(
|
|
175
|
-
undefined, true, attachmentsDir(record.workspacePath),
|
|
176
|
-
)
|
|
228
|
+
const composition = await this.presetComposition(undefined, true, sessionId)
|
|
177
229
|
const handle = await this.deps.ctx.agents.resume({
|
|
178
230
|
resumeSessionId: sessionId,
|
|
179
231
|
agentOptions: this.agentOptions(),
|
|
@@ -187,6 +239,7 @@ export class SessionManager {
|
|
|
187
239
|
throw new Error('dsh-channels: session resume aborted by shutdown')
|
|
188
240
|
}
|
|
189
241
|
this.requestPolicy(handle.agent)
|
|
242
|
+
await this.mountGuidance(handle.agent, sessionDirOf(record))
|
|
190
243
|
const liveAgent: LiveAgent = { agent: handle.agent, handle }
|
|
191
244
|
this.live.set(sessionId, liveAgent)
|
|
192
245
|
return liveAgent
|
|
@@ -194,7 +247,7 @@ export class SessionManager {
|
|
|
194
247
|
// Lost a cold-resume race: another owner published the session while our
|
|
195
248
|
// resume was in flight. Borrow the winner instead of failing the turn.
|
|
196
249
|
const winner = this.deps.ctx.agents.get(sessionId)
|
|
197
|
-
if (winner !== undefined) return this.borrow(sessionId, winner)
|
|
250
|
+
if (winner !== undefined) return await this.borrow(sessionId, winner)
|
|
198
251
|
throw error
|
|
199
252
|
}
|
|
200
253
|
}
|
|
@@ -224,17 +277,23 @@ export class SessionManager {
|
|
|
224
277
|
return controller
|
|
225
278
|
}
|
|
226
279
|
|
|
227
|
-
/**
|
|
280
|
+
/**
|
|
281
|
+
* Create, place, workspace-attach, and atomically publish one renewed route.
|
|
282
|
+
*
|
|
283
|
+
* A renewal keeps the topic's working directory: the new Session is created
|
|
284
|
+
* with the user workspace as its header cwd (so it stays grouped under the
|
|
285
|
+
* project) and then re-entered into the same topic directory or worktree.
|
|
286
|
+
*/
|
|
228
287
|
async renew(record: RouteRecord, oldLive: LiveAgent): Promise<RenewCommit> {
|
|
229
288
|
const sessionId = randomUUID() as SessionId
|
|
230
289
|
const preset = this.effectivePreset(oldLive.agent.session)
|
|
231
|
-
const composition = await this.presetComposition(preset, false,
|
|
290
|
+
const composition = await this.presetComposition(preset, false, sessionId)
|
|
291
|
+
const services: PlacementServices = placementServices(this.deps.ctx)
|
|
292
|
+
const switchable = services.workingDirectory !== undefined
|
|
293
|
+
const workspacePath = record.workspacePath
|
|
232
294
|
const workspace = await this.deps.ctx.workspaceRegistry.create(
|
|
233
|
-
|
|
234
|
-
workspaceTitleFor(
|
|
235
|
-
record.chatType, record.chatId, record.senderOpenId,
|
|
236
|
-
record.kind === 'thread' ? record.key : record.sessionId,
|
|
237
|
-
),
|
|
295
|
+
workspacePath,
|
|
296
|
+
workspaceTitleFor(record.chatType, record.chatId, record.senderOpenId),
|
|
238
297
|
)
|
|
239
298
|
|
|
240
299
|
let newHandle: AgentHandle | undefined
|
|
@@ -243,23 +302,38 @@ export class SessionManager {
|
|
|
243
302
|
newHandle = await this.deps.ctx.agents.create({
|
|
244
303
|
sessionId,
|
|
245
304
|
meta: {
|
|
246
|
-
|
|
305
|
+
// Without an effective-directory service the Session must be born in
|
|
306
|
+
// its topic directory; otherwise the workspace root keeps it grouped.
|
|
307
|
+
cwd: switchable ? workspacePath : sessionDirOf(record),
|
|
247
308
|
...(composition.agentPreset === undefined ? {} : { agentPreset: composition.agentPreset }),
|
|
248
309
|
},
|
|
249
310
|
agentOptions: { ...oldLive.agent.options },
|
|
250
311
|
setup: composition.setup,
|
|
251
312
|
})
|
|
252
313
|
this.requestPolicy(newHandle.agent)
|
|
253
|
-
await
|
|
254
|
-
|
|
314
|
+
const placement: TopicPlacement = await restoreTopicDir(services, newHandle.agent, {
|
|
315
|
+
workspacePath,
|
|
316
|
+
sessionDir: sessionDirOf(record),
|
|
317
|
+
...(record.worktreeBranch === undefined ? {} : { worktreeBranch: record.worktreeBranch }),
|
|
318
|
+
}, this.deps.warn.bind(this.deps))
|
|
319
|
+
if (switchable) {
|
|
320
|
+
await workspace.attachSession(sessionId)
|
|
321
|
+
attached = true
|
|
322
|
+
}
|
|
323
|
+
await this.mountGuidance(newHandle.agent, placement.sessionDir)
|
|
255
324
|
const createdAt = newHandle.agent.session.header.createdAt
|
|
256
325
|
|
|
257
|
-
const
|
|
326
|
+
const base: RouteRecord = {
|
|
258
327
|
...record,
|
|
259
328
|
sessionId,
|
|
260
329
|
sessionHistory: appendSessionHistory(record, sessionId, createdAt),
|
|
261
330
|
lastActivityAt: Date.now(),
|
|
331
|
+
sessionDir: placement.sessionDir,
|
|
262
332
|
}
|
|
333
|
+
delete base.worktreeBranch
|
|
334
|
+
const current: RouteRecord = placement.worktreeBranch === undefined
|
|
335
|
+
? base
|
|
336
|
+
: { ...base, worktreeBranch: placement.worktreeBranch }
|
|
263
337
|
await this.deps.persistRoute(record.key, current)
|
|
264
338
|
this.live.set(sessionId, { agent: newHandle.agent, handle: newHandle })
|
|
265
339
|
return { previous: record, current, oldLive }
|
|
@@ -308,11 +382,15 @@ export class SessionManager {
|
|
|
308
382
|
* resumes. The id resolves BEFORE the session exists because the session
|
|
309
383
|
* boundary snapshots `meta` before asynchronous setup begins; mounting stays
|
|
310
384
|
* inside `setup`, where a failure rolls the whole creation back.
|
|
385
|
+
*
|
|
386
|
+
* The attachment guidance is mounted later through {@link mountGuidance},
|
|
387
|
+
* because a Git worktree's checkout path only exists after the Agent (and so
|
|
388
|
+
* the worktree) has been created.
|
|
311
389
|
*/
|
|
312
390
|
private async presetComposition(
|
|
313
391
|
requestedPreset: string | undefined = this.deps.config.agentPreset,
|
|
314
392
|
useRosterDefault = true,
|
|
315
|
-
|
|
393
|
+
sessionId?: string,
|
|
316
394
|
): Promise<PresetComposition> {
|
|
317
395
|
// Read through ctx.get (safe lookup): only the web profile ships a roster,
|
|
318
396
|
// and direct property access on a cordis context throws for services the
|
|
@@ -324,7 +402,7 @@ export class SessionManager {
|
|
|
324
402
|
// cannot guarantee that the explicit channel value wins.
|
|
325
403
|
this.mountRequestPolicy(agentCtx)
|
|
326
404
|
await mountChannelCommands(agentCtx)
|
|
327
|
-
await
|
|
405
|
+
if (sessionId !== undefined) await this.mountToolsFor(agentCtx, sessionId)
|
|
328
406
|
}
|
|
329
407
|
if (presets === undefined || (requestedPreset === undefined && !useRosterDefault)) {
|
|
330
408
|
return { setup: channelCommandsOnly }
|
|
@@ -336,11 +414,30 @@ export class SessionManager {
|
|
|
336
414
|
this.mountRequestPolicy(agentCtx)
|
|
337
415
|
await presets.mount(agentCtx, resolved.id)
|
|
338
416
|
await mountChannelCommands(agentCtx)
|
|
339
|
-
await
|
|
417
|
+
if (sessionId !== undefined) await this.mountToolsFor(agentCtx, sessionId)
|
|
340
418
|
},
|
|
341
419
|
}
|
|
342
420
|
}
|
|
343
421
|
|
|
422
|
+
/**
|
|
423
|
+
* Mount the channel model tools into one Agent scope, at most once per
|
|
424
|
+
* scope. A failure is logged and swallowed: missing tools never fail a
|
|
425
|
+
* session (attachment fetch degrades to its structured failures).
|
|
426
|
+
*/
|
|
427
|
+
private async mountToolsFor(agentCtx: AgentContext, sessionId: string): Promise<void> {
|
|
428
|
+
if (this.deps.mountTools === undefined) return
|
|
429
|
+
if (this.toolMounts.has(agentCtx)) return
|
|
430
|
+
try {
|
|
431
|
+
await this.deps.mountTools(agentCtx, sessionId)
|
|
432
|
+
this.toolMounts.add(agentCtx)
|
|
433
|
+
} catch (error) {
|
|
434
|
+
this.deps.warn(
|
|
435
|
+
'dsh-channels: mounting channel tools for session %s failed: %s',
|
|
436
|
+
sessionId, String(error),
|
|
437
|
+
)
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
344
441
|
/** Effective preset after folding any durable runtime preset switch. */
|
|
345
442
|
private effectivePreset(session: Session): string | undefined {
|
|
346
443
|
let selected = session.header.agentPreset
|
package/src/session/repair.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* @module dsh-channels/session/repair
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { buildInterruptionCard } from '../cards/streaming.ts'
|
|
14
|
+
import { buildInterruptionCard, segmentCardOptions } from '../cards/streaming.ts'
|
|
15
15
|
import type { InflightSnapshot } from '../domain.ts'
|
|
16
16
|
import type { FeishuChannel } from '../feishu-channel.ts'
|
|
17
17
|
import type { FeishuDomainHandle } from '../domain.ts'
|
|
@@ -146,8 +146,7 @@ export class InflightRepair {
|
|
|
146
146
|
if (card.mode !== 'card') continue
|
|
147
147
|
// Full replacement: the interrupted card's streamed content lived only
|
|
148
148
|
// in the previous process. Continuation cards keep their segment header.
|
|
149
|
-
|
|
150
|
-
await channel.updateCard(card.messageId, buildInterruptionCard(options))
|
|
149
|
+
await channel.updateCard(card.messageId, buildInterruptionCard(segmentCardOptions(index)))
|
|
151
150
|
}
|
|
152
151
|
if (snapshot.reactionId !== undefined) {
|
|
153
152
|
await channel.removeReaction(snapshot.triggerMessageId, snapshot.reactionId).catch(() => {})
|
package/src/session/routes.ts
CHANGED
|
@@ -148,6 +148,8 @@ export class RouteStore {
|
|
|
148
148
|
senderOpenId: record.senderOpenId,
|
|
149
149
|
expiredAt: now,
|
|
150
150
|
workspacePath: record.workspacePath,
|
|
151
|
+
...(record.sessionDir === undefined ? {} : { sessionDir: record.sessionDir }),
|
|
152
|
+
...(record.worktreeBranch === undefined ? {} : { worktreeBranch: record.worktreeBranch }),
|
|
151
153
|
lastActivityAt: record.lastActivityAt,
|
|
152
154
|
}
|
|
153
155
|
await this.deps.domain()?.expiredRoutes.put(key, tombstone)
|