@modesto_ma/dsh-channels 0.1.0 → 0.3.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 +89 -55
- package/docs/access-control-design.md +5 -5
- package/docs/feasibility-and-design.md +54 -26
- package/docs/feishu-onboarding.md +26 -20
- package/docs/persistence.md +4 -4
- package/docs/refactor-plan.md +6 -5
- package/docs/workspace-isolation-design.md +78 -0
- package/lib/client.js +1715 -882
- package/lib/client.js.map +1 -1
- package/lib/index.js +1556 -680
- 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 +256 -119
- package/src/client/api.ts +20 -0
- package/src/client/catalog.ts +1 -1
- package/src/client/index.ts +1 -1
- package/src/client/panel/access-card.tsx +294 -0
- package/src/client/panel/access-tab.tsx +46 -0
- package/src/client/panel/advanced-tab.tsx +80 -0
- package/src/client/panel/cleanup-card.tsx +106 -0
- package/src/client/panel/connection-tab.tsx +214 -0
- package/src/client/panel/credentials-card.tsx +104 -0
- package/src/client/panel/index.tsx +862 -0
- package/src/client/panel/model-tab.tsx +23 -0
- package/src/client/panel/sessions-tab.tsx +21 -0
- package/src/client/panel/shared.tsx +488 -0
- package/src/client/panel/workspace-tab.tsx +40 -0
- package/src/client/styles.ts +24 -6
- package/src/config.ts +44 -12
- package/src/domain.ts +34 -3
- package/src/feishu/agent-context.ts +9 -5
- package/src/index.ts +32 -14
- package/src/routing/inbound.ts +88 -43
- package/src/service.ts +259 -65
- package/src/session/isolation.ts +277 -0
- package/src/session/manager.ts +63 -20
- 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 +223 -47
- package/src/settings/directory-picker.ts +121 -0
- package/src/settings/routes.ts +49 -2
- package/src/settings/surface.ts +171 -25
- package/src/streamer.ts +70 -30
- package/src/workspaces.ts +50 -28
- package/src/client/panel.tsx +0 -1475
|
@@ -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,14 @@ 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
32
|
import { installRequestPolicy } from './request-policy.ts'
|
|
33
33
|
import type { RequestPolicyController } from './request-policy.ts'
|
|
34
|
+
import { placementServices, restoreTopicDir } from './isolation.ts'
|
|
35
|
+
import type { PlacementServices, TopicPlacement } from './isolation.ts'
|
|
34
36
|
import { workspaceTitleFor } from '../workspaces.ts'
|
|
35
37
|
|
|
36
38
|
/** One live agent plus this plugin's ownership handle. */
|
|
@@ -105,14 +107,16 @@ export class SessionManager {
|
|
|
105
107
|
|
|
106
108
|
/**
|
|
107
109
|
* Create the root agent for one freshly routed session and register it live.
|
|
108
|
-
*
|
|
110
|
+
* `cwd` is the Session header cwd (the user workspace when the deployment can
|
|
111
|
+
* switch directories). The caller owns placement, the workspace attachment,
|
|
112
|
+
* the attachment guidance, and the failure card.
|
|
109
113
|
*/
|
|
110
114
|
async createSession(
|
|
111
115
|
sessionId: SessionId,
|
|
112
116
|
cwd: string,
|
|
113
117
|
agentOptions: Record<string, unknown> = this.agentOptions(),
|
|
114
118
|
): Promise<AgentHandle> {
|
|
115
|
-
const composition = await this.presetComposition(undefined, true
|
|
119
|
+
const composition = await this.presetComposition(undefined, true)
|
|
116
120
|
const handle = await this.deps.ctx.agents.create({
|
|
117
121
|
sessionId,
|
|
118
122
|
meta: {
|
|
@@ -127,6 +131,24 @@ export class SessionManager {
|
|
|
127
131
|
return handle
|
|
128
132
|
}
|
|
129
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Register the attachment-directory guidance for one Session. Called after
|
|
136
|
+
* the effective working directory is known (a worktree checkout path is
|
|
137
|
+
* only returned once the worktree exists), so the prompt section can name
|
|
138
|
+
* the absolute directory. Best-effort: a missing system-prompt service
|
|
139
|
+
* (headless) is a no-op and a late registration never blocks the turn.
|
|
140
|
+
*/
|
|
141
|
+
async mountGuidance(agent: Agent, directory: string): Promise<void> {
|
|
142
|
+
try {
|
|
143
|
+
await mountChannelGuidance(agent.ctx, attachmentsDir(directory))
|
|
144
|
+
} catch (error) {
|
|
145
|
+
this.deps.warn(
|
|
146
|
+
'dsh-channels: mounting attachment guidance for session %s failed: %s',
|
|
147
|
+
agent.id, String(error),
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
130
152
|
/**
|
|
131
153
|
* Live agent for a route: this plugin's own → a foreign live agent (borrow)
|
|
132
154
|
* → a serialized cold resume.
|
|
@@ -171,9 +193,7 @@ export class SessionManager {
|
|
|
171
193
|
// (config override or roster default). A session persisted before the
|
|
172
194
|
// preset fix has no preset recorded on its header; re-resolving here is
|
|
173
195
|
// what repairs it on the first resume after a restart.
|
|
174
|
-
const composition = await this.presetComposition(
|
|
175
|
-
undefined, true, attachmentsDir(record.workspacePath),
|
|
176
|
-
)
|
|
196
|
+
const composition = await this.presetComposition(undefined, true)
|
|
177
197
|
const handle = await this.deps.ctx.agents.resume({
|
|
178
198
|
resumeSessionId: sessionId,
|
|
179
199
|
agentOptions: this.agentOptions(),
|
|
@@ -187,6 +207,7 @@ export class SessionManager {
|
|
|
187
207
|
throw new Error('dsh-channels: session resume aborted by shutdown')
|
|
188
208
|
}
|
|
189
209
|
this.requestPolicy(handle.agent)
|
|
210
|
+
await this.mountGuidance(handle.agent, sessionDirOf(record))
|
|
190
211
|
const liveAgent: LiveAgent = { agent: handle.agent, handle }
|
|
191
212
|
this.live.set(sessionId, liveAgent)
|
|
192
213
|
return liveAgent
|
|
@@ -224,17 +245,23 @@ export class SessionManager {
|
|
|
224
245
|
return controller
|
|
225
246
|
}
|
|
226
247
|
|
|
227
|
-
/**
|
|
248
|
+
/**
|
|
249
|
+
* Create, place, workspace-attach, and atomically publish one renewed route.
|
|
250
|
+
*
|
|
251
|
+
* A renewal keeps the topic's working directory: the new Session is created
|
|
252
|
+
* with the user workspace as its header cwd (so it stays grouped under the
|
|
253
|
+
* project) and then re-entered into the same topic directory or worktree.
|
|
254
|
+
*/
|
|
228
255
|
async renew(record: RouteRecord, oldLive: LiveAgent): Promise<RenewCommit> {
|
|
229
256
|
const sessionId = randomUUID() as SessionId
|
|
230
257
|
const preset = this.effectivePreset(oldLive.agent.session)
|
|
231
|
-
const composition = await this.presetComposition(preset, false
|
|
258
|
+
const composition = await this.presetComposition(preset, false)
|
|
259
|
+
const services: PlacementServices = placementServices(this.deps.ctx)
|
|
260
|
+
const switchable = services.workingDirectory !== undefined
|
|
261
|
+
const workspacePath = record.workspacePath
|
|
232
262
|
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
|
-
),
|
|
263
|
+
workspacePath,
|
|
264
|
+
workspaceTitleFor(record.chatType, record.chatId, record.senderOpenId),
|
|
238
265
|
)
|
|
239
266
|
|
|
240
267
|
let newHandle: AgentHandle | undefined
|
|
@@ -243,23 +270,38 @@ export class SessionManager {
|
|
|
243
270
|
newHandle = await this.deps.ctx.agents.create({
|
|
244
271
|
sessionId,
|
|
245
272
|
meta: {
|
|
246
|
-
|
|
273
|
+
// Without an effective-directory service the Session must be born in
|
|
274
|
+
// its topic directory; otherwise the workspace root keeps it grouped.
|
|
275
|
+
cwd: switchable ? workspacePath : sessionDirOf(record),
|
|
247
276
|
...(composition.agentPreset === undefined ? {} : { agentPreset: composition.agentPreset }),
|
|
248
277
|
},
|
|
249
278
|
agentOptions: { ...oldLive.agent.options },
|
|
250
279
|
setup: composition.setup,
|
|
251
280
|
})
|
|
252
281
|
this.requestPolicy(newHandle.agent)
|
|
253
|
-
await
|
|
254
|
-
|
|
282
|
+
const placement: TopicPlacement = await restoreTopicDir(services, newHandle.agent, {
|
|
283
|
+
workspacePath,
|
|
284
|
+
sessionDir: sessionDirOf(record),
|
|
285
|
+
...(record.worktreeBranch === undefined ? {} : { worktreeBranch: record.worktreeBranch }),
|
|
286
|
+
}, this.deps.warn.bind(this.deps))
|
|
287
|
+
if (switchable) {
|
|
288
|
+
await workspace.attachSession(sessionId)
|
|
289
|
+
attached = true
|
|
290
|
+
}
|
|
291
|
+
await this.mountGuidance(newHandle.agent, placement.sessionDir)
|
|
255
292
|
const createdAt = newHandle.agent.session.header.createdAt
|
|
256
293
|
|
|
257
|
-
const
|
|
294
|
+
const base: RouteRecord = {
|
|
258
295
|
...record,
|
|
259
296
|
sessionId,
|
|
260
297
|
sessionHistory: appendSessionHistory(record, sessionId, createdAt),
|
|
261
298
|
lastActivityAt: Date.now(),
|
|
299
|
+
sessionDir: placement.sessionDir,
|
|
262
300
|
}
|
|
301
|
+
delete base.worktreeBranch
|
|
302
|
+
const current: RouteRecord = placement.worktreeBranch === undefined
|
|
303
|
+
? base
|
|
304
|
+
: { ...base, worktreeBranch: placement.worktreeBranch }
|
|
263
305
|
await this.deps.persistRoute(record.key, current)
|
|
264
306
|
this.live.set(sessionId, { agent: newHandle.agent, handle: newHandle })
|
|
265
307
|
return { previous: record, current, oldLive }
|
|
@@ -308,11 +350,14 @@ export class SessionManager {
|
|
|
308
350
|
* resumes. The id resolves BEFORE the session exists because the session
|
|
309
351
|
* boundary snapshots `meta` before asynchronous setup begins; mounting stays
|
|
310
352
|
* inside `setup`, where a failure rolls the whole creation back.
|
|
353
|
+
*
|
|
354
|
+
* The attachment guidance is mounted later through {@link mountGuidance},
|
|
355
|
+
* because a Git worktree's checkout path only exists after the Agent (and so
|
|
356
|
+
* the worktree) has been created.
|
|
311
357
|
*/
|
|
312
358
|
private async presetComposition(
|
|
313
359
|
requestedPreset: string | undefined = this.deps.config.agentPreset,
|
|
314
360
|
useRosterDefault = true,
|
|
315
|
-
attachmentsDir?: string,
|
|
316
361
|
): Promise<PresetComposition> {
|
|
317
362
|
// Read through ctx.get (safe lookup): only the web profile ships a roster,
|
|
318
363
|
// and direct property access on a cordis context throws for services the
|
|
@@ -324,7 +369,6 @@ export class SessionManager {
|
|
|
324
369
|
// cannot guarantee that the explicit channel value wins.
|
|
325
370
|
this.mountRequestPolicy(agentCtx)
|
|
326
371
|
await mountChannelCommands(agentCtx)
|
|
327
|
-
await mountChannelGuidance(agentCtx, attachmentsDir)
|
|
328
372
|
}
|
|
329
373
|
if (presets === undefined || (requestedPreset === undefined && !useRosterDefault)) {
|
|
330
374
|
return { setup: channelCommandsOnly }
|
|
@@ -336,7 +380,6 @@ export class SessionManager {
|
|
|
336
380
|
this.mountRequestPolicy(agentCtx)
|
|
337
381
|
await presets.mount(agentCtx, resolved.id)
|
|
338
382
|
await mountChannelCommands(agentCtx)
|
|
339
|
-
await mountChannelGuidance(agentCtx, attachmentsDir)
|
|
340
383
|
},
|
|
341
384
|
}
|
|
342
385
|
}
|
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)
|
package/src/session-commands.ts
CHANGED
|
@@ -61,8 +61,8 @@ export interface SessionStatusCardInput {
|
|
|
61
61
|
export interface SessionStatusExtras {
|
|
62
62
|
/** Effective permission preset of this Session (`ctx.permissionPresets`). */
|
|
63
63
|
permissionPreset?: string
|
|
64
|
-
/**
|
|
65
|
-
|
|
64
|
+
/** Effective working directory of this Session (its topic directory). */
|
|
65
|
+
sessionDir?: string
|
|
66
66
|
/** Plugin uptime in milliseconds. */
|
|
67
67
|
uptimeMs?: number
|
|
68
68
|
/** Live agent entries this channel owns/borrows. */
|
|
@@ -123,7 +123,7 @@ function statusExtrasElements(extras: SessionStatusExtras | undefined): object[]
|
|
|
123
123
|
if (extras === undefined) return []
|
|
124
124
|
const lines: string[] = []
|
|
125
125
|
if (extras.permissionPreset !== undefined) lines.push(`**权限预设**:\`${escapeInlineCode(extras.permissionPreset)}\``)
|
|
126
|
-
if (extras.
|
|
126
|
+
if (extras.sessionDir !== undefined) lines.push(`**会话目录**:\`${escapeInlineCode(extras.sessionDir)}\``)
|
|
127
127
|
if (extras.uptimeMs !== undefined) lines.push(`**插件运行时长**:${fmtUptime(extras.uptimeMs)}`)
|
|
128
128
|
if (extras.liveAgents !== undefined || extras.routeCount !== undefined) {
|
|
129
129
|
const parts: string[] = []
|
|
@@ -148,33 +148,59 @@ export type WorkDirResolution =
|
|
|
148
148
|
| { kind: 'missing'; text: string }
|
|
149
149
|
| { kind: 'mismatch'; text: string }
|
|
150
150
|
|
|
151
|
-
/**
|
|
152
|
-
export
|
|
151
|
+
/** The durable route facts needed to verify a Session's working directory. */
|
|
152
|
+
export interface WorkDirRoute {
|
|
153
|
+
/** Workspace root owning the Session (its header cwd when grouped). */
|
|
154
|
+
readonly workspacePath: string
|
|
155
|
+
/** Effective working directory (topic directory / worktree); absent on v1 routes. */
|
|
156
|
+
readonly sessionDir?: string
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Validate the Session header cwd against the plugin's durable route and
|
|
161
|
+
* return the Session's effective working directory.
|
|
162
|
+
*
|
|
163
|
+
* The header cwd is normally the workspace root (the Session stays grouped
|
|
164
|
+
* under the user's project while working in a per-topic directory). In the
|
|
165
|
+
* degraded placement used when the host exposes no `workingDirectory` service,
|
|
166
|
+
* the header cwd is the topic directory itself.
|
|
167
|
+
*/
|
|
168
|
+
export function resolveWorkDir(
|
|
169
|
+
headerCwd: string | undefined,
|
|
170
|
+
route: WorkDirRoute,
|
|
171
|
+
): WorkDirResolution {
|
|
153
172
|
if (headerCwd === undefined || headerCwd === '' || !path.isAbsolute(headerCwd)) {
|
|
154
173
|
return {
|
|
155
174
|
kind: 'missing',
|
|
156
175
|
text: '当前 Session header 未记录可用的绝对工作目录;请检查 Session 创建或恢复配置。',
|
|
157
176
|
}
|
|
158
177
|
}
|
|
159
|
-
if (!path.isAbsolute(
|
|
178
|
+
if (!path.isAbsolute(route.workspacePath)) {
|
|
179
|
+
return {
|
|
180
|
+
kind: 'missing',
|
|
181
|
+
text: '当前飞书路由未记录可用的工作区目录;请检查路由持久化状态。',
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (route.sessionDir !== undefined && !path.isAbsolute(route.sessionDir)) {
|
|
160
185
|
return {
|
|
161
186
|
kind: 'missing',
|
|
162
|
-
text: '
|
|
187
|
+
text: '当前飞书路由未记录可用的会话目录;请检查路由持久化状态。',
|
|
163
188
|
}
|
|
164
189
|
}
|
|
165
|
-
|
|
190
|
+
const expected = route.sessionDir ?? route.workspacePath
|
|
191
|
+
if (headerCwd !== route.workspacePath && headerCwd !== expected) {
|
|
166
192
|
return {
|
|
167
193
|
kind: 'mismatch',
|
|
168
194
|
text: 'Session header 与飞书路由记录的工作目录不一致;为避免返回错误目录,已拒绝显示。',
|
|
169
195
|
}
|
|
170
196
|
}
|
|
171
|
-
return { kind: 'success', path:
|
|
197
|
+
return { kind: 'success', path: expected }
|
|
172
198
|
}
|
|
173
199
|
|
|
174
|
-
/** Render one verified absolute
|
|
175
|
-
export function buildWorkDirCard(sessionId: string,
|
|
200
|
+
/** Render one verified absolute working directory. */
|
|
201
|
+
export function buildWorkDirCard(sessionId: string, sessionDir: string): object {
|
|
176
202
|
return standaloneCard('**📁 当前工作目录**', [
|
|
177
|
-
{ tag: 'markdown', content: `\`${escapeInlineCode(
|
|
203
|
+
{ tag: 'markdown', content: `\`${escapeInlineCode(sessionDir)}\`` },
|
|
178
204
|
{ tag: 'markdown', content: sessionId, text_size: 'notation' },
|
|
179
205
|
])
|
|
180
206
|
}
|
|
@@ -182,7 +208,8 @@ export function buildWorkDirCard(sessionId: string, workspacePath: string): obje
|
|
|
182
208
|
export interface RenewCardInput {
|
|
183
209
|
previousSessionId: string
|
|
184
210
|
currentSessionId: string
|
|
185
|
-
|
|
211
|
+
/** Topic directory that the renewed Session reuses. */
|
|
212
|
+
sessionDir: string
|
|
186
213
|
}
|
|
187
214
|
|
|
188
215
|
/** Render the committed route switch without implying that the old log was deleted. */
|
|
@@ -192,7 +219,7 @@ export function buildRenewCard(input: RenewCardInput): object {
|
|
|
192
219
|
tag: 'markdown',
|
|
193
220
|
content: `**旧 Session**:${input.previousSessionId}\n`
|
|
194
221
|
+ `**新 Session**:${input.currentSessionId}\n`
|
|
195
|
-
+
|
|
222
|
+
+ `**复用目录**:\`${escapeInlineCode(input.sessionDir)}\``,
|
|
196
223
|
},
|
|
197
224
|
{ tag: 'markdown', content: '旧 Session 及其日志仍由 Harness 保留。', text_size: 'notation' },
|
|
198
225
|
])
|