@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
package/src/config.ts
CHANGED
|
@@ -21,6 +21,10 @@ export type FeishuLoggerLevel = (typeof FEISHU_LOGGER_LEVELS)[number]
|
|
|
21
21
|
/** Same shape as the host credentials seam's `CredentialRef` (shell identifier). */
|
|
22
22
|
export const CREDENTIAL_REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
23
23
|
|
|
24
|
+
/** Conventional credential-reference names used when the loader passes no config. */
|
|
25
|
+
export const DEFAULT_APP_ID_REF = 'FEISHU_APP_ID'
|
|
26
|
+
export const DEFAULT_APP_SECRET_REF = 'FEISHU_APP_SECRET'
|
|
27
|
+
|
|
24
28
|
/** Default route-table GC threshold: 30 days without activity. */
|
|
25
29
|
export const DEFAULT_ROUTE_TTL_MS = 30 * 24 * 60 * 60 * 1000
|
|
26
30
|
|
|
@@ -37,10 +41,10 @@ const credentialRefSchema = z.string().regex(
|
|
|
37
41
|
)
|
|
38
42
|
|
|
39
43
|
export const configSchema = z.object({
|
|
40
|
-
/** Feishu app id credential reference (
|
|
41
|
-
appId: credentialRefSchema,
|
|
42
|
-
/** Feishu app secret credential reference (
|
|
43
|
-
appSecret: credentialRefSchema,
|
|
44
|
+
/** Feishu app id credential reference (defaults to `FEISHU_APP_ID`). */
|
|
45
|
+
appId: credentialRefSchema.default(DEFAULT_APP_ID_REF),
|
|
46
|
+
/** Feishu app secret credential reference (defaults to `FEISHU_APP_SECRET`). */
|
|
47
|
+
appSecret: credentialRefSchema.default(DEFAULT_APP_SECRET_REF),
|
|
44
48
|
/**
|
|
45
49
|
* Root directory for per-(user, chat) workspace directories — the filesystem
|
|
46
50
|
* root where files produced by channel sessions land. Read per session
|
|
@@ -99,6 +103,23 @@ export const configSchema = z.object({
|
|
|
99
103
|
* `strict` fails the reply instead.
|
|
100
104
|
*/
|
|
101
105
|
threadFallback: z.enum(['degrade', 'strict']).default('degrade'),
|
|
106
|
+
/**
|
|
107
|
+
* Per-topic working-directory isolation inside one user workspace.
|
|
108
|
+
*
|
|
109
|
+
* - `auto` (default): use a Git worktree per topic when the user workspace
|
|
110
|
+
* is a Git repository (the workspace directory contains `.git`), otherwise
|
|
111
|
+
* a plain subdirectory.
|
|
112
|
+
* - `subdir`: always a plain subdirectory (no Git requirement, no duplicated
|
|
113
|
+
* checkouts).
|
|
114
|
+
* - `worktree`: always try a Git worktree; when the workspace is not a Git
|
|
115
|
+
* repository, the `worktrees` service is unavailable, or creation is
|
|
116
|
+
* denied, log a warning and fall back to a subdirectory.
|
|
117
|
+
*
|
|
118
|
+
* The workspace itself stays the Session header cwd either way, so Sessions
|
|
119
|
+
* remain grouped under the user's project; only the effective working
|
|
120
|
+
* directory changes. See `docs/workspace-isolation-design.md`.
|
|
121
|
+
*/
|
|
122
|
+
topicIsolation: z.enum(['auto', 'subdir', 'worktree']).default('auto'),
|
|
102
123
|
/**
|
|
103
124
|
* Route-table GC threshold. Route records (and stale in-flight repair
|
|
104
125
|
* snapshots) whose last activity is older than this many milliseconds are
|
|
@@ -125,17 +146,19 @@ export const configSchema = z.object({
|
|
|
125
146
|
*/
|
|
126
147
|
acceptAttachments: z.boolean().default(true),
|
|
127
148
|
/**
|
|
128
|
-
* Delete the
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
149
|
+
* Delete the topic directories of stale conversations, plus legacy
|
|
150
|
+
* one-workspace-per-topic directories written by older plugin versions.
|
|
151
|
+
* `off` (default) never deletes; `7d`/`30d`/`90d`/`180d` remove those
|
|
152
|
+
* directories when their last activity is older than the threshold. User
|
|
153
|
+
* workspaces themselves are never deleted. The managed Web panel exposes
|
|
154
|
+
* this as a select, previews the candidates, and can run one cleanup
|
|
155
|
+
* manually. DSH Session logs are never deleted.
|
|
133
156
|
*/
|
|
134
157
|
workspaceRetention: z.enum(WORKSPACE_RETENTION_VALUES).default('off'),
|
|
135
158
|
/**
|
|
136
|
-
* Run the
|
|
137
|
-
*
|
|
138
|
-
*
|
|
159
|
+
* Run the retention cleanup at startup and once per day. Off by default:
|
|
160
|
+
* deletion is irreversible, so it stays an explicit opt-in even when a
|
|
161
|
+
* retention threshold is configured.
|
|
139
162
|
*/
|
|
140
163
|
workspaceRetentionAuto: z.boolean().default(false),
|
|
141
164
|
/**
|
|
@@ -188,6 +211,15 @@ export const configSchema = z.object({
|
|
|
188
211
|
|
|
189
212
|
export type FeishuConfig = z.infer<typeof configSchema>
|
|
190
213
|
|
|
214
|
+
/**
|
|
215
|
+
* DSH loader-facing schema. The bundle patch inserts this plugin without a
|
|
216
|
+
* `config` object; the loader validates that row with `undefined` before the
|
|
217
|
+
* user's patch layer supplies values. Accept that absence and fill the same
|
|
218
|
+
* defaults as {@link resolveConfig}, so enabling the plugin from the Web UI
|
|
219
|
+
* cannot fail with "expected object, received undefined".
|
|
220
|
+
*/
|
|
221
|
+
export const pluginConfigSchema = configSchema.prefault({})
|
|
222
|
+
|
|
191
223
|
/**
|
|
192
224
|
* Validate the raw loader config. Values stay credential *references*; the
|
|
193
225
|
* secrets themselves resolve through `ctx.credentials` at startup.
|
package/src/domain.ts
CHANGED
|
@@ -10,8 +10,16 @@
|
|
|
10
10
|
|
|
11
11
|
import { z } from 'zod'
|
|
12
12
|
|
|
13
|
-
/**
|
|
14
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Route key kinds. `thread` = real Feishu topic; `chat` = degraded chat-scoped
|
|
15
|
+
* fallback.
|
|
16
|
+
*
|
|
17
|
+
* Version 2 adds the optional `sessionDir` / `worktreeBranch` fields (the
|
|
18
|
+
* Session's effective working directory). Version-1 records stay readable: an
|
|
19
|
+
* absent `sessionDir` means the workspace path itself is the working directory
|
|
20
|
+
* (the pre-v2 model).
|
|
21
|
+
*/
|
|
22
|
+
export const ROUTE_RECORD_VERSION = 2
|
|
15
23
|
|
|
16
24
|
export const sessionHistoryEntrySchema = z.object({
|
|
17
25
|
/** One Session that has been bound to this route. */
|
|
@@ -35,8 +43,16 @@ export const routeRecordSchema = z.object({
|
|
|
35
43
|
chatType: z.enum(['p2p', 'group']),
|
|
36
44
|
/** Workspace owner (sender open_id). */
|
|
37
45
|
senderOpenId: z.string().min(1),
|
|
38
|
-
/** Workspace
|
|
46
|
+
/** Workspace root owning this route: the Session header cwd / sandbox root. */
|
|
39
47
|
workspacePath: z.string().min(1),
|
|
48
|
+
/**
|
|
49
|
+
* Effective working directory of the bound Session (the per-topic directory
|
|
50
|
+
* or Git worktree checkout). Absent on records written before per-topic
|
|
51
|
+
* directories existed: `workspacePath` is then the working directory too.
|
|
52
|
+
*/
|
|
53
|
+
sessionDir: z.string().min(1).optional(),
|
|
54
|
+
/** Git branch owning `sessionDir` when it is a worktree checkout. */
|
|
55
|
+
worktreeBranch: z.string().min(1).optional(),
|
|
40
56
|
/** Message id of the latest streaming card (updated per turn). */
|
|
41
57
|
cardMessageId: z.string().min(1),
|
|
42
58
|
/** Epoch ms when the record was created. */
|
|
@@ -71,6 +87,12 @@ export const expiredRouteRecordSchema = z.object({
|
|
|
71
87
|
/** Workspace the retired route belonged to; absent in tombstones written by
|
|
72
88
|
* older plugin versions, so the field must stay optional. */
|
|
73
89
|
workspacePath: z.string().min(1).optional(),
|
|
90
|
+
/** Effective working directory the retired route's Session used; absent in
|
|
91
|
+
* legacy tombstones (retention cleanup then treats `workspacePath` as the
|
|
92
|
+
* directory, matching pre-v2 routes). */
|
|
93
|
+
sessionDir: z.string().min(1).optional(),
|
|
94
|
+
/** Git branch of that directory when it was a worktree checkout. */
|
|
95
|
+
worktreeBranch: z.string().min(1).optional(),
|
|
74
96
|
/** Epoch ms of the last activity through the route before retirement.
|
|
75
97
|
* Optional for the same legacy-compatibility reason; consumers fall back to
|
|
76
98
|
* `expiredAt` when it is absent. */
|
|
@@ -116,6 +138,15 @@ export function appendSessionHistory(
|
|
|
116
138
|
})
|
|
117
139
|
}
|
|
118
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Effective working directory of one route's Session. Records written before
|
|
143
|
+
* per-topic directories have no `sessionDir`; their workspace path is the
|
|
144
|
+
* directory the Session really works in.
|
|
145
|
+
*/
|
|
146
|
+
export function sessionDirOf(record: Pick<RouteRecord, 'workspacePath' | 'sessionDir'>): string {
|
|
147
|
+
return record.sessionDir ?? record.workspacePath
|
|
148
|
+
}
|
|
149
|
+
|
|
119
150
|
/**
|
|
120
151
|
* In-flight turn snapshot: everything needed to repair a streaming card that a
|
|
121
152
|
* restart left mid-flight (stuck panel, leftover Typing reaction, never-patched
|
|
@@ -2,12 +2,16 @@
|
|
|
2
2
|
* Agent-scoped system-prompt guidance for Feishu attachments.
|
|
3
3
|
*
|
|
4
4
|
* The channel archives inbound images/files/forwarded records below
|
|
5
|
-
* `<
|
|
6
|
-
* makes that convention discoverable: it tells the Agent where
|
|
7
|
-
* live, how to read them, and that reading is opt-in per user
|
|
5
|
+
* `<session working directory>/attachments/` without any model turn. This
|
|
6
|
+
* section is what makes that convention discoverable: it tells the Agent where
|
|
7
|
+
* attachments live, how to read them, and that reading is opt-in per user
|
|
8
|
+
* request.
|
|
8
9
|
*
|
|
9
10
|
* Registered inside the Agent scope (through `inject(['systemPrompt'])`), so
|
|
10
11
|
* it is disposed with the Agent and does not leak into other compositions.
|
|
12
|
+
* It is mounted only after the Session's effective working directory is known
|
|
13
|
+
* (see `SessionManager.mountGuidance`), so the absolute path is accurate even
|
|
14
|
+
* when that directory is a Git worktree created after Agent creation.
|
|
11
15
|
*
|
|
12
16
|
* @module dsh-channels/feishu/agent-context
|
|
13
17
|
*/
|
|
@@ -26,13 +30,13 @@ const FEISHU_ATTACHMENTS_ORDER = 950
|
|
|
26
30
|
|
|
27
31
|
/** Model-facing text. Kept stable so unchanged sessions keep their KV cache. */
|
|
28
32
|
export const FEISHU_ATTACHMENTS_GUIDANCE = `## Feishu attachments
|
|
29
|
-
Every Feishu conversation (topic) has its own workspace and its own attachments directory. Images, files, and forwarded chat records sent through Feishu are archived verbatim in this conversation's \`attachments/\` directory. Each file name starts with its receipt time (\`YYYYMMDD-HHmmss-\`), so newer attachments sort later and \`ls -lt <dir>\` lists the newest first.
|
|
33
|
+
Every Feishu conversation (topic) has its own working directory inside its user's workspace, and every working directory has its own \`attachments/\` directory. Images, files, and forwarded chat records sent through Feishu are archived verbatim in this conversation's \`attachments/\` directory. Each file name starts with its receipt time (\`YYYYMMDD-HHmmss-\`), so newer attachments sort later and \`ls -lt <dir>\` lists the newest first.
|
|
30
34
|
|
|
31
35
|
Attachments are never inserted into the conversation automatically. Read them only when the user's request needs them, and do not read attachments the user did not mention:
|
|
32
36
|
- images: use \`read_image\` with the file path;
|
|
33
37
|
- text and code: use \`read\` when the session mounts that tool, otherwise \`bash\` (for example \`sed -n '1,200p' <file>\`);
|
|
34
38
|
- other formats (PDF, Office, archives, ...): inspect or convert them with \`bash\`.
|
|
35
|
-
If the user refers to "the file/image I just sent" without a name, list this conversation's attachments directory and pick the most recent matching attachment. Other Feishu conversations
|
|
39
|
+
If the user refers to "the file/image I just sent" without a name, list this conversation's attachments directory and pick the most recent matching attachment. Other Feishu conversations work in separate directories: never read outside this conversation's working directory, and never mention files from another conversation.`
|
|
36
40
|
|
|
37
41
|
/**
|
|
38
42
|
* Full guidance for one Session. The absolute per-session directory is
|
package/src/index.ts
CHANGED
|
@@ -3,19 +3,21 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 一个 dsh 实例对应一个飞书机器人;用户通过私聊、群聊 @、话题群 @ 三种方式
|
|
5
5
|
* 发起会话。每条触发消息:立即加 “Typing” 表情 → 以话题形式回复(创建话题)
|
|
6
|
-
* → 绑定 DSH session → 卡片 2.0 流式回复(推理折叠面板 +
|
|
7
|
-
*
|
|
6
|
+
* → 绑定 DSH session → 卡片 2.0 流式回复(推理折叠面板 + 临时活动行 +
|
|
7
|
+
* 富文本正文 + 结算页脚,默认 500ms 刷新)→ 流式结束移除 Typing,成功更新为
|
|
8
8
|
* “DONE”,失败显示结构化错误并更新为 “No”。
|
|
9
9
|
*
|
|
10
10
|
* 工作区归属:私聊一人一工作区;群聊中每个用户在该群拥有独立工作区
|
|
11
|
-
*
|
|
11
|
+
* (工作区是会话 header cwd,保证 GUI 归属与沙箱边界)。每个话题在该工作区
|
|
12
|
+
* 内拥有独立的工作目录(`topics/<话题>`,Git 仓库工作区可自动改用 worktree),
|
|
13
|
+
* 因此话题之间的文件互不干扰,而工作区数量只随用户增长。
|
|
12
14
|
*
|
|
13
15
|
* @module dsh-channels
|
|
14
16
|
*/
|
|
15
17
|
|
|
16
18
|
import type { HostContext } from './dsh-types.ts'
|
|
17
19
|
import { FeishuService } from './service.ts'
|
|
18
|
-
import {
|
|
20
|
+
import { pluginConfigSchema, resolveConfig } from './config.ts'
|
|
19
21
|
import type { FeishuConfig } from './config.ts'
|
|
20
22
|
import { defaultChannelFactory } from './feishu-channel.ts'
|
|
21
23
|
import type { ChannelFactory } from './feishu-channel.ts'
|
|
@@ -23,6 +25,7 @@ import { OverlayStore, defaultOverlayPath } from './settings/overlay.ts'
|
|
|
23
25
|
import { mountSettingsRoutes } from './settings/routes.ts'
|
|
24
26
|
import { SettingsSurface } from './settings/surface.ts'
|
|
25
27
|
import { createHostCatalog } from './settings/catalog.ts'
|
|
28
|
+
import { createSettingsDirectoryPicker } from './settings/directory-picker.ts'
|
|
26
29
|
|
|
27
30
|
export type * from './config.ts'
|
|
28
31
|
export type * from './cards.ts'
|
|
@@ -38,16 +41,19 @@ export type * from './questions.ts'
|
|
|
38
41
|
export type * from './settings/contract.ts'
|
|
39
42
|
export * from './access/index.ts'
|
|
40
43
|
export type { AccessGroupRecord, AccessRequestRecord, AccessUserRecord } from './domain.ts'
|
|
41
|
-
export { CATALOG_ROUTE, CLEANUP_ROUTE, CONFIG_ROUTE, CREDENTIALS_ROUTE, EDITABLE_KEYS, PANEL_COPY, PANEL_ROUTE, RECONNECT_ROUTE, RESTART_KEYS, STATUS_ROUTE } from './settings/contract.ts'
|
|
44
|
+
export { CATALOG_ROUTE, CLEANUP_ROUTE, CONFIG_ROUTE, CREDENTIALS_ROUTE, EDITABLE_KEYS, PANEL_COPY, PANEL_ROUTE, PICK_DIRECTORY_ROUTE, RECONNECT_ROUTE, RESTART_KEYS, STATUS_ROUTE } from './settings/contract.ts'
|
|
45
|
+
export type { PickDirectoryResponse } from './settings/contract.ts'
|
|
42
46
|
export { defaultOverlayPath, mergeOverlay, normalizeOverlay, OverlayStore, parseOverlay, pickEditable, resolveDshHome } from './settings/overlay.ts'
|
|
43
|
-
export { applyCatalog, emptyCatalog, FIELD_DESCRIPTORS, normalizeCredentialValue, readOnlyFields, SettingsSurface } from './settings/surface.ts'
|
|
47
|
+
export { applyCatalog, emptyCatalog, FIELD_DESCRIPTORS, normalizeCredentialValue, readOnlyFields, SettingsSurface, withDirectoryBrowse } from './settings/surface.ts'
|
|
44
48
|
export type { SettingsTarget } from './settings/surface.ts'
|
|
45
49
|
export { createHostCatalog } from './settings/catalog.ts'
|
|
46
50
|
export type { CatalogWarn, SettingsCatalog } from './settings/catalog.ts'
|
|
51
|
+
export { createSettingsDirectoryPicker } from './settings/directory-picker.ts'
|
|
52
|
+
export type { HostDirectoryPickerService, SettingsDirectoryPicker } from './settings/directory-picker.ts'
|
|
47
53
|
export { isSameOriginRequest, mountSettingsRoutes, registerSettingsRoutes } from './settings/routes.ts'
|
|
48
54
|
export type { HostWebServer } from './settings/routes.ts'
|
|
49
55
|
export type { ApprovalOutcome, ApprovalRequest, ApprovalAnswererListener, AssistantStreamFrame, AskUserQuestionAnswer, AskUserQuestionAnswerItem, AskUserQuestionItem, AskUserQuestionRequestEvent, UserQuestionAnswererListener, AgentCancelCause, SessionStatsView, TokenUsageView, ContextPressureView, SessionProjections } from './dsh-types.ts'
|
|
50
|
-
export { buildCard, buildInterruptionCard, cardJsonBytes, CARD_MAX_BYTES, CARD_TARGET_BYTES,
|
|
56
|
+
export { buildCard, buildInterruptionCard, cardJsonBytes, CARD_MAX_BYTES, CARD_TARGET_BYTES, REASONING_MAX_BYTES, INTERRUPTION_NOTICE, MAX_TABLES_PER_CARD, PANEL_TITLES, PANEL_CHEVRON_TOKEN, segmentCardContent, splitBodyForCard, stripTables, tableBlocks, TRUNCATION_SUFFIX, buildApprovalCard, buildApprovalSettledCard, buildQuestionCard, buildQuestionSettledCard, MAX_QUESTION_OPTIONS } from './cards.ts'
|
|
51
57
|
export { ApprovalAnswerer } from './approvals.ts'
|
|
52
58
|
export { QuestionAnswerer } from './questions.ts'
|
|
53
59
|
export { armModelCommand, buildCommandResultCard, CHANNEL_COMMAND_NAMES, commandResultCardKind, isSupportedCommand, parseSlashCommand, registerChannelCommands, registerModelCommand, RouteOperationQueue, SUPPORTED_COMMAND_NAMES, supportedCommandHelp, supportedCommandList } from './commands.ts'
|
|
@@ -57,8 +63,8 @@ export { createLogicalSessionExport, DSH_CHANNELS_VERSION, LOGICAL_EXPORT_FORMAT
|
|
|
57
63
|
export { addUsage, applyProjectionViews, averageDecodeTokensPerSecond, averageTtftMs, cacheHitPct, EMPTY_TOTALS, foldAll, foldEvent, foldSessionStats, measureSessionContext, readProjectionViews, usageFromBuckets } from './stats.ts'
|
|
58
64
|
export { FeishuService } from './service.ts'
|
|
59
65
|
export { TurnStreamer } from './streamer.ts'
|
|
60
|
-
export { ensureWorkspace, stagingPathFor, topicTokenFor, workspacePathFor, workspaceTitleFor } from './workspaces.ts'
|
|
61
|
-
export { accessGroupRecordSchema, accessRequestRecordSchema, accessUserRecordSchema, appendSessionHistory, chatRouteKey, expiredRouteRecordSchema, feishuDomain, normalizeRouteRecord, normalizeSessionHistory, routeRecordSchema, sessionHistoryEntrySchema, inflightSnapshotSchema, processedRecordSchema } from './domain.ts'
|
|
66
|
+
export { ensureWorkspace, legacyTopicWorkspaceRoot, stagingPathFor, topicDirFor, topicTokenFor, workspacePathFor, workspaceTitleFor, worktreeBranchFor } from './workspaces.ts'
|
|
67
|
+
export { accessGroupRecordSchema, accessRequestRecordSchema, accessUserRecordSchema, appendSessionHistory, chatRouteKey, expiredRouteRecordSchema, feishuDomain, normalizeRouteRecord, normalizeSessionHistory, routeRecordSchema, sessionDirOf, sessionHistoryEntrySchema, inflightSnapshotSchema, processedRecordSchema } from './domain.ts'
|
|
62
68
|
|
|
63
69
|
/** Cordis function-plugin name. */
|
|
64
70
|
export const name = 'channels'
|
|
@@ -83,7 +89,7 @@ export const inject = ['agents', 'commands', 'workspaceRegistry', 'storageDomain
|
|
|
83
89
|
* its `resolveConfig()` fallback for direct invocation (unit tests, embedded
|
|
84
90
|
* hosts) where config has not been pre-validated.
|
|
85
91
|
*/
|
|
86
|
-
export const Config =
|
|
92
|
+
export const Config = pluginConfigSchema
|
|
87
93
|
|
|
88
94
|
export interface FeishuPluginOptions extends FeishuConfig {
|
|
89
95
|
/** Test seam: substitute a channel implementation. */
|
|
@@ -106,16 +112,28 @@ export function apply(ctx: HostContext, config?: unknown): void {
|
|
|
106
112
|
)
|
|
107
113
|
const service = new FeishuService(ctx, resolved, factory, undefined, overlay)
|
|
108
114
|
// Settings routes ride the host's web server when this deployment has one.
|
|
109
|
-
// The catalog resolves the panel's model selectors from live host services
|
|
115
|
+
// The catalog resolves the panel's model selectors from live host services;
|
|
116
|
+
// the optional native directory picker backs the workspace-root「选择目录…」
|
|
117
|
+
// button (absent in headless/remote compositions → plain text input).
|
|
110
118
|
const warn = (message: string, ...args: unknown[]): void => { ctx.logger?.warn?.(message, ...args) }
|
|
111
|
-
mountSettingsRoutes(ctx, new SettingsSurface(
|
|
119
|
+
mountSettingsRoutes(ctx, new SettingsSurface(
|
|
120
|
+
service,
|
|
121
|
+
overlay,
|
|
122
|
+
{ warn },
|
|
123
|
+
createHostCatalog(ctx, warn),
|
|
124
|
+
createSettingsDirectoryPicker(ctx),
|
|
125
|
+
))
|
|
112
126
|
ctx.effect(() => {
|
|
127
|
+
// Boot loads the Web overlay and connects only when the Feishu application
|
|
128
|
+
// credentials are complete; an incomplete configuration or a failed
|
|
129
|
+
// connection is a panel-visible state, not a plugin failure. Only an
|
|
130
|
+
// unexpected boot error (e.g. unreadable overlay) reaches this catch.
|
|
113
131
|
void service.boot().catch((error: unknown) => {
|
|
114
|
-
ctx.logger?.error?.('dsh-channels failed to
|
|
132
|
+
ctx.logger?.error?.('dsh-channels failed to boot: %s', String(error))
|
|
115
133
|
// Critical lifecycle line duplicated to the console: cordis logger
|
|
116
134
|
// messages emitted before a web exporter attaches are buffered and may
|
|
117
135
|
// never reach stdout, which makes real-device debugging guesswork.
|
|
118
|
-
console.error('[dsh-channels] failed to
|
|
136
|
+
console.error('[dsh-channels] failed to boot:', error)
|
|
119
137
|
})
|
|
120
138
|
return () => service.shutdown()
|
|
121
139
|
}, 'dsh-channels')
|
package/src/routing/inbound.ts
CHANGED
|
@@ -14,7 +14,7 @@ import type { AgentHandle, HostContext, SessionId, UserMessage, Workspace } from
|
|
|
14
14
|
import type { FeishuConfig } from '../config.ts'
|
|
15
15
|
import { buildCard, buildOnboardingCard, initialStreamingCard } from '../cards.ts'
|
|
16
16
|
import { RouteOperationQueue } from '../commands.ts'
|
|
17
|
-
import { chatRouteKey } from '../domain.ts'
|
|
17
|
+
import { chatRouteKey, sessionDirOf } from '../domain.ts'
|
|
18
18
|
import type { FeishuDomainHandle, RouteRecord } from '../domain.ts'
|
|
19
19
|
import type { CardDelivery } from '../feishu/delivery.ts'
|
|
20
20
|
import {
|
|
@@ -28,6 +28,10 @@ import type { InflightRepair } from '../session/repair.ts'
|
|
|
28
28
|
import type { SessionManager } from '../session/manager.ts'
|
|
29
29
|
import type { RouteStore } from '../session/routes.ts'
|
|
30
30
|
import type { TurnStreamer } from '../streamer.ts'
|
|
31
|
+
import {
|
|
32
|
+
enterTopic, placementServices, planTopic, restoreTopicDir,
|
|
33
|
+
} from '../session/isolation.ts'
|
|
34
|
+
import type { PlacementServices, TopicPlacement, TopicPlan } from '../session/isolation.ts'
|
|
31
35
|
import { ensureWorkspace, stagingPathFor, topicTokenFor } from '../workspaces.ts'
|
|
32
36
|
|
|
33
37
|
/** The command plane the inbound router delegates admission-time work to. */
|
|
@@ -313,12 +317,12 @@ export class InboundRouter {
|
|
|
313
317
|
|
|
314
318
|
return await this.routeOperations.run(record?.key ?? chatKey, async () => {
|
|
315
319
|
if (!this.started) return { saved: 0, failed: 0, failures: [], attachmentOnly: true, text: '' }
|
|
316
|
-
// An existing topic has
|
|
317
|
-
// stages files below baseDir until its first
|
|
318
|
-
//
|
|
320
|
+
// An existing topic already has its own working directory; a
|
|
321
|
+
// conversation without one stages files below baseDir until its first
|
|
322
|
+
// Session creates the topic directory and adopts them.
|
|
319
323
|
const directory = record === undefined
|
|
320
324
|
? stagingPathFor(this.config.baseDir, msg.chatType, msg.chatId, msg.senderId)
|
|
321
|
-
: attachmentsDir(record
|
|
325
|
+
: attachmentsDir(sessionDirOf(record))
|
|
322
326
|
return await this.attachments.archive(msg, directory)
|
|
323
327
|
})
|
|
324
328
|
}
|
|
@@ -395,25 +399,20 @@ export class InboundRouter {
|
|
|
395
399
|
const initialStats = { sessionId, inputTokens: 0, outputTokens: 0 }
|
|
396
400
|
|
|
397
401
|
// 2. First card frame — also creates the topic (reply_in_thread). The
|
|
398
|
-
// topic identity is only known after Feishu answers, and it
|
|
399
|
-
//
|
|
402
|
+
// topic identity is only known after Feishu answers, and it names the
|
|
403
|
+
// topic's private working directory inside the user's workspace.
|
|
400
404
|
const sent = await this.delivery.replyFirstCard(msg, initialStreamingCard(sessionId))
|
|
401
405
|
const typingId = await typingPromise
|
|
402
406
|
const key = sent.degraded || sent.threadId === undefined ? chatKey : sent.threadId
|
|
403
407
|
const kind = sent.degraded || sent.threadId === undefined ? 'chat' : 'thread'
|
|
404
408
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
await adoptStagedAttachments(
|
|
413
|
-
stagingPathFor(this.config.baseDir, msg.chatType, msg.chatId, msg.senderId),
|
|
414
|
-
workspace.path,
|
|
415
|
-
)
|
|
416
|
-
} catch (error) {
|
|
409
|
+
const topicToken = topicTokenFor(key, kind, sessionId)
|
|
410
|
+
const services: PlacementServices = placementServices(this.ctx)
|
|
411
|
+
const warn = (message: string, ...args: unknown[]): void => {
|
|
412
|
+
this.ctx.logger?.warn?.(message, ...args)
|
|
413
|
+
}
|
|
414
|
+
/** Patch the streaming card to its terminal creation-failure state. */
|
|
415
|
+
const failCreation = async (error: unknown): Promise<void> => {
|
|
417
416
|
void this.repair.clear(sent.messageId)
|
|
418
417
|
await this.delivery.requireChannel().updateCard(sent.messageId, buildCard({
|
|
419
418
|
phase: 'done',
|
|
@@ -421,6 +420,20 @@ export class InboundRouter {
|
|
|
421
420
|
body: `会话创建失败:${String(error)}`,
|
|
422
421
|
stats: { ...initialStats },
|
|
423
422
|
})).catch(() => {})
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// 3. User workspace (the durable project this Session is grouped under)
|
|
426
|
+
// plus the per-topic placement plan. No Agent exists yet; the plan
|
|
427
|
+
// materializes anything that must exist before creation.
|
|
428
|
+
let workspace: Workspace
|
|
429
|
+
let plan: TopicPlan
|
|
430
|
+
try {
|
|
431
|
+
workspace = await ensureWorkspace(
|
|
432
|
+
this.ctx, this.config.baseDir, msg.chatType, msg.chatId, msg.senderId,
|
|
433
|
+
)
|
|
434
|
+
plan = await planTopic(services, this.config, workspace.path, topicToken, warn)
|
|
435
|
+
} catch (error) {
|
|
436
|
+
await failCreation(error)
|
|
424
437
|
throw error
|
|
425
438
|
}
|
|
426
439
|
|
|
@@ -439,27 +452,39 @@ export class InboundRouter {
|
|
|
439
452
|
attempts: 0,
|
|
440
453
|
})
|
|
441
454
|
|
|
442
|
-
// 4. Create the
|
|
443
|
-
|
|
455
|
+
// 4. Create the Session (header cwd = user workspace when directories can
|
|
456
|
+
// be switched), then enter its own topic directory / worktree.
|
|
457
|
+
let handle: AgentHandle | undefined
|
|
458
|
+
let placement: TopicPlacement | undefined
|
|
444
459
|
try {
|
|
445
|
-
handle = await this.sessions.createSession(sessionId,
|
|
460
|
+
handle = await this.sessions.createSession(sessionId, plan.cwd)
|
|
461
|
+
placement = await enterTopic(services, handle.agent, plan, workspace.path, topicToken, warn)
|
|
462
|
+
// A workspace attachment only validates when the header cwd is the
|
|
463
|
+
// workspace itself; the hard fallback (no workingDirectory service) must
|
|
464
|
+
// skip it instead of logging a guaranteed failure.
|
|
465
|
+
if (plan.cwd === workspace.path) {
|
|
466
|
+
void Promise.resolve(workspace.attachSession(sessionId)).catch((error: unknown) => {
|
|
467
|
+
warn('dsh-channels: attaching initial session %s to workspace failed: %s', sessionId, String(error))
|
|
468
|
+
})
|
|
469
|
+
}
|
|
470
|
+
// Files staged before this topic existed belong to this conversation.
|
|
471
|
+
await adoptStagedAttachments(
|
|
472
|
+
stagingPathFor(this.config.baseDir, msg.chatType, msg.chatId, msg.senderId),
|
|
473
|
+
placement.sessionDir,
|
|
474
|
+
).catch((error: unknown) => {
|
|
475
|
+
warn('dsh-channels: adopting staged attachments failed: %s', String(error))
|
|
476
|
+
})
|
|
477
|
+
await this.sessions.mountGuidance(handle.agent, placement.sessionDir)
|
|
446
478
|
} catch (error) {
|
|
447
|
-
// The card lands in a final failure state — no repair needed later.
|
|
448
|
-
|
|
449
|
-
await
|
|
450
|
-
|
|
451
|
-
reasoning: '',
|
|
452
|
-
body: `会话创建失败:${String(error)}`,
|
|
453
|
-
stats: { ...initialStats },
|
|
454
|
-
})).catch(() => {})
|
|
479
|
+
// The card lands in a final failure state — no repair needed later. A
|
|
480
|
+
// Session created just before the failure must not stay live unnoticed.
|
|
481
|
+
await handle?.dispose().catch(() => {})
|
|
482
|
+
await failCreation(error)
|
|
455
483
|
throw error
|
|
456
484
|
}
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
sessionId, String(error),
|
|
461
|
-
)
|
|
462
|
-
})
|
|
485
|
+
if (handle === undefined || placement === undefined) {
|
|
486
|
+
throw new Error('dsh-channels: session creation completed without a handle')
|
|
487
|
+
}
|
|
463
488
|
|
|
464
489
|
// 5. Persist the route (thread-scoped, or degraded chat-scoped).
|
|
465
490
|
const createdAt = handle.agent.session.header.createdAt
|
|
@@ -471,6 +496,8 @@ export class InboundRouter {
|
|
|
471
496
|
chatType: msg.chatType,
|
|
472
497
|
senderOpenId: msg.senderId,
|
|
473
498
|
workspacePath: workspace.path,
|
|
499
|
+
sessionDir: placement.sessionDir,
|
|
500
|
+
...(placement.worktreeBranch === undefined ? {} : { worktreeBranch: placement.worktreeBranch }),
|
|
474
501
|
cardMessageId: sent.messageId,
|
|
475
502
|
createdAt,
|
|
476
503
|
lastActivityAt: createdAt,
|
|
@@ -487,24 +514,42 @@ export class InboundRouter {
|
|
|
487
514
|
// Defence in depth: every current caller checks this first, but a future
|
|
488
515
|
// call path must not be able to continue another user's Session.
|
|
489
516
|
if (!this.access.ownsRoute(msg, record)) return
|
|
517
|
+
const services = placementServices(this.ctx)
|
|
518
|
+
const warn = (message: string, ...args: unknown[]): void => {
|
|
519
|
+
this.ctx.logger?.warn?.(message, ...args)
|
|
520
|
+
}
|
|
521
|
+
const typingPromise = this.delivery.requireChannel().addReaction(msg.messageId, 'Typing').catch(() => undefined)
|
|
522
|
+
const live = await this.sessions.ensureLive(record)
|
|
523
|
+
// The topic directory can disappear independently of the Session (manual
|
|
524
|
+
// deletion, external cleanup). Recreate it and re-align the effective
|
|
525
|
+
// working directory so the turn never leaks into the shared workspace root.
|
|
526
|
+
const placement = await restoreTopicDir(services, live.agent, {
|
|
527
|
+
workspacePath: record.workspacePath,
|
|
528
|
+
sessionDir: sessionDirOf(record),
|
|
529
|
+
...(record.worktreeBranch === undefined ? {} : { worktreeBranch: record.worktreeBranch }),
|
|
530
|
+
}, warn)
|
|
531
|
+
let current: RouteRecord = record
|
|
532
|
+
if (placement.sessionDir !== sessionDirOf(record) || placement.worktreeBranch !== record.worktreeBranch) {
|
|
533
|
+
current = { ...record, sessionDir: placement.sessionDir }
|
|
534
|
+
delete current.worktreeBranch
|
|
535
|
+
if (placement.worktreeBranch !== undefined) current = { ...current, worktreeBranch: placement.worktreeBranch }
|
|
536
|
+
}
|
|
490
537
|
// Files staged before this conversation's topic existed follow its next
|
|
491
538
|
// turn (for example a file sent in the chat before the first prompt).
|
|
492
539
|
await adoptStagedAttachments(
|
|
493
540
|
stagingPathFor(this.config.baseDir, msg.chatType, msg.chatId, msg.senderId),
|
|
494
|
-
|
|
541
|
+
placement.sessionDir,
|
|
495
542
|
).catch((error: unknown) => {
|
|
496
543
|
this.ctx.logger?.warn?.('dsh-channels: adopting staged attachments failed: %s', String(error))
|
|
497
544
|
})
|
|
498
|
-
const typingPromise = this.delivery.requireChannel().addReaction(msg.messageId, 'Typing').catch(() => undefined)
|
|
499
|
-
const live = await this.sessions.ensureLive(record)
|
|
500
545
|
|
|
501
|
-
const sent = await this.delivery.replyFirstCard(msg, initialStreamingCard(
|
|
546
|
+
const sent = await this.delivery.replyFirstCard(msg, initialStreamingCard(current.sessionId))
|
|
502
547
|
const typingId = await typingPromise
|
|
503
548
|
|
|
504
549
|
// Repair snapshot first — same crash-window reasoning as `startSession`.
|
|
505
550
|
await this.repair.write({
|
|
506
551
|
key: sent.messageId,
|
|
507
|
-
sessionId:
|
|
552
|
+
sessionId: current.sessionId,
|
|
508
553
|
chatId: msg.chatId,
|
|
509
554
|
triggerMessageId: msg.messageId,
|
|
510
555
|
reactionId: typingId,
|
|
@@ -513,8 +558,8 @@ export class InboundRouter {
|
|
|
513
558
|
attempts: 0,
|
|
514
559
|
})
|
|
515
560
|
|
|
516
|
-
await this.routes.put(
|
|
517
|
-
...
|
|
561
|
+
await this.routes.put(current.key, {
|
|
562
|
+
...current,
|
|
518
563
|
cardMessageId: sent.messageId,
|
|
519
564
|
lastActivityAt: Date.now(),
|
|
520
565
|
})
|