dsh-gufa-code 0.0.0-stage → 1.0.1

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.
Files changed (158) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +93 -2
  3. package/client.js +11548 -0
  4. package/cordis.patch.yml +8 -0
  5. package/docs/icon-180.png +0 -0
  6. package/docs/icon.svg +18 -0
  7. package/docs/market-submission.md +32 -0
  8. package/docs/screenshot-ide.jpg +0 -0
  9. package/host/assets.js +31 -0
  10. package/host/chat.js +523 -0
  11. package/host/context.js +438 -0
  12. package/host/dev.js +35 -0
  13. package/host/fs.js +306 -0
  14. package/host/git.js +532 -0
  15. package/host/project.js +305 -0
  16. package/host/ssh.js +54 -0
  17. package/host/terminal-pty.js +237 -0
  18. package/host/terminal.js +947 -0
  19. package/icon.svg +18 -0
  20. package/index.js +63 -0
  21. package/locale/en.json +121 -0
  22. package/locale/zh.json +121 -0
  23. package/package.json +67 -4
  24. package/screenshots.json +3 -0
  25. package/scripts/build-client.mjs +38 -0
  26. package/scripts/selftest-client.mjs +457 -0
  27. package/scripts/selftest-host.mjs +197 -0
  28. package/scripts/selftest-http.mjs +284 -0
  29. package/scripts/selftest-router.mjs +101 -0
  30. package/scripts/selftest-terminal-client.mjs +314 -0
  31. package/scripts/selftest-terminal-pty.mjs +190 -0
  32. package/scripts/selftest-terminal.mjs +351 -0
  33. package/scripts/test-chat-render.mjs +702 -0
  34. package/scripts/test-chat.mjs +761 -0
  35. package/scripts/test-editor.mjs +516 -0
  36. package/scripts/verify-workspace-sessions.mjs +135 -0
  37. package/src/client/00-core.js +1686 -0
  38. package/src/client/10-icons.js +205 -0
  39. package/src/client/20-editor.js +1318 -0
  40. package/src/client/30-terminal.js +1510 -0
  41. package/src/client/40-chat.js +1184 -0
  42. package/src/client/46-gitgraph.js +278 -0
  43. package/src/client/50-panels.js +916 -0
  44. package/src/client/55-backdrop.js +508 -0
  45. package/src/client/60-dock.js +662 -0
  46. package/src/client/85-shortcuts.js +516 -0
  47. package/src/client/90-app.js +2272 -0
  48. package/src/client/99-main.js +449 -0
  49. package/vendor/monaco/LICENSE +21 -0
  50. package/vendor/monaco/ThirdPartyNotices.txt +448 -0
  51. package/vendor/monaco/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
  52. package/vendor/monaco/vs/base/worker/workerMain.js +31 -0
  53. package/vendor/monaco/vs/basic-languages/abap/abap.js +10 -0
  54. package/vendor/monaco/vs/basic-languages/apex/apex.js +10 -0
  55. package/vendor/monaco/vs/basic-languages/azcli/azcli.js +10 -0
  56. package/vendor/monaco/vs/basic-languages/bat/bat.js +10 -0
  57. package/vendor/monaco/vs/basic-languages/bicep/bicep.js +11 -0
  58. package/vendor/monaco/vs/basic-languages/cameligo/cameligo.js +10 -0
  59. package/vendor/monaco/vs/basic-languages/clojure/clojure.js +10 -0
  60. package/vendor/monaco/vs/basic-languages/coffee/coffee.js +10 -0
  61. package/vendor/monaco/vs/basic-languages/cpp/cpp.js +10 -0
  62. package/vendor/monaco/vs/basic-languages/csharp/csharp.js +10 -0
  63. package/vendor/monaco/vs/basic-languages/csp/csp.js +10 -0
  64. package/vendor/monaco/vs/basic-languages/css/css.js +12 -0
  65. package/vendor/monaco/vs/basic-languages/cypher/cypher.js +10 -0
  66. package/vendor/monaco/vs/basic-languages/dart/dart.js +10 -0
  67. package/vendor/monaco/vs/basic-languages/dockerfile/dockerfile.js +10 -0
  68. package/vendor/monaco/vs/basic-languages/ecl/ecl.js +10 -0
  69. package/vendor/monaco/vs/basic-languages/elixir/elixir.js +10 -0
  70. package/vendor/monaco/vs/basic-languages/flow9/flow9.js +10 -0
  71. package/vendor/monaco/vs/basic-languages/freemarker2/freemarker2.js +12 -0
  72. package/vendor/monaco/vs/basic-languages/fsharp/fsharp.js +10 -0
  73. package/vendor/monaco/vs/basic-languages/go/go.js +10 -0
  74. package/vendor/monaco/vs/basic-languages/graphql/graphql.js +10 -0
  75. package/vendor/monaco/vs/basic-languages/handlebars/handlebars.js +10 -0
  76. package/vendor/monaco/vs/basic-languages/hcl/hcl.js +10 -0
  77. package/vendor/monaco/vs/basic-languages/html/html.js +10 -0
  78. package/vendor/monaco/vs/basic-languages/ini/ini.js +10 -0
  79. package/vendor/monaco/vs/basic-languages/java/java.js +10 -0
  80. package/vendor/monaco/vs/basic-languages/javascript/javascript.js +10 -0
  81. package/vendor/monaco/vs/basic-languages/julia/julia.js +10 -0
  82. package/vendor/monaco/vs/basic-languages/kotlin/kotlin.js +10 -0
  83. package/vendor/monaco/vs/basic-languages/less/less.js +11 -0
  84. package/vendor/monaco/vs/basic-languages/lexon/lexon.js +10 -0
  85. package/vendor/monaco/vs/basic-languages/liquid/liquid.js +10 -0
  86. package/vendor/monaco/vs/basic-languages/lua/lua.js +10 -0
  87. package/vendor/monaco/vs/basic-languages/m3/m3.js +10 -0
  88. package/vendor/monaco/vs/basic-languages/markdown/markdown.js +10 -0
  89. package/vendor/monaco/vs/basic-languages/mdx/mdx.js +10 -0
  90. package/vendor/monaco/vs/basic-languages/mips/mips.js +10 -0
  91. package/vendor/monaco/vs/basic-languages/msdax/msdax.js +10 -0
  92. package/vendor/monaco/vs/basic-languages/mysql/mysql.js +10 -0
  93. package/vendor/monaco/vs/basic-languages/objective-c/objective-c.js +10 -0
  94. package/vendor/monaco/vs/basic-languages/pascal/pascal.js +10 -0
  95. package/vendor/monaco/vs/basic-languages/pascaligo/pascaligo.js +10 -0
  96. package/vendor/monaco/vs/basic-languages/perl/perl.js +10 -0
  97. package/vendor/monaco/vs/basic-languages/pgsql/pgsql.js +10 -0
  98. package/vendor/monaco/vs/basic-languages/php/php.js +10 -0
  99. package/vendor/monaco/vs/basic-languages/pla/pla.js +10 -0
  100. package/vendor/monaco/vs/basic-languages/postiats/postiats.js +10 -0
  101. package/vendor/monaco/vs/basic-languages/powerquery/powerquery.js +10 -0
  102. package/vendor/monaco/vs/basic-languages/powershell/powershell.js +10 -0
  103. package/vendor/monaco/vs/basic-languages/protobuf/protobuf.js +11 -0
  104. package/vendor/monaco/vs/basic-languages/pug/pug.js +10 -0
  105. package/vendor/monaco/vs/basic-languages/python/python.js +10 -0
  106. package/vendor/monaco/vs/basic-languages/qsharp/qsharp.js +10 -0
  107. package/vendor/monaco/vs/basic-languages/r/r.js +10 -0
  108. package/vendor/monaco/vs/basic-languages/razor/razor.js +10 -0
  109. package/vendor/monaco/vs/basic-languages/redis/redis.js +10 -0
  110. package/vendor/monaco/vs/basic-languages/redshift/redshift.js +10 -0
  111. package/vendor/monaco/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
  112. package/vendor/monaco/vs/basic-languages/ruby/ruby.js +10 -0
  113. package/vendor/monaco/vs/basic-languages/rust/rust.js +10 -0
  114. package/vendor/monaco/vs/basic-languages/sb/sb.js +10 -0
  115. package/vendor/monaco/vs/basic-languages/scala/scala.js +10 -0
  116. package/vendor/monaco/vs/basic-languages/scheme/scheme.js +10 -0
  117. package/vendor/monaco/vs/basic-languages/scss/scss.js +12 -0
  118. package/vendor/monaco/vs/basic-languages/shell/shell.js +10 -0
  119. package/vendor/monaco/vs/basic-languages/solidity/solidity.js +10 -0
  120. package/vendor/monaco/vs/basic-languages/sophia/sophia.js +10 -0
  121. package/vendor/monaco/vs/basic-languages/sparql/sparql.js +10 -0
  122. package/vendor/monaco/vs/basic-languages/sql/sql.js +10 -0
  123. package/vendor/monaco/vs/basic-languages/st/st.js +10 -0
  124. package/vendor/monaco/vs/basic-languages/swift/swift.js +13 -0
  125. package/vendor/monaco/vs/basic-languages/systemverilog/systemverilog.js +10 -0
  126. package/vendor/monaco/vs/basic-languages/tcl/tcl.js +10 -0
  127. package/vendor/monaco/vs/basic-languages/twig/twig.js +10 -0
  128. package/vendor/monaco/vs/basic-languages/typescript/typescript.js +10 -0
  129. package/vendor/monaco/vs/basic-languages/typespec/typespec.js +10 -0
  130. package/vendor/monaco/vs/basic-languages/vb/vb.js +10 -0
  131. package/vendor/monaco/vs/basic-languages/wgsl/wgsl.js +307 -0
  132. package/vendor/monaco/vs/basic-languages/xml/xml.js +10 -0
  133. package/vendor/monaco/vs/basic-languages/yaml/yaml.js +10 -0
  134. package/vendor/monaco/vs/editor/editor.main.css +8 -0
  135. package/vendor/monaco/vs/editor/editor.main.js +798 -0
  136. package/vendor/monaco/vs/language/css/cssMode.js +13 -0
  137. package/vendor/monaco/vs/language/css/cssWorker.js +77 -0
  138. package/vendor/monaco/vs/language/html/htmlMode.js +13 -0
  139. package/vendor/monaco/vs/language/html/htmlWorker.js +454 -0
  140. package/vendor/monaco/vs/language/json/jsonMode.js +19 -0
  141. package/vendor/monaco/vs/language/json/jsonWorker.js +42 -0
  142. package/vendor/monaco/vs/language/typescript/tsMode.js +20 -0
  143. package/vendor/monaco/vs/language/typescript/tsWorker.js +51328 -0
  144. package/vendor/monaco/vs/loader.js +11 -0
  145. package/vendor/monaco/vs/nls.messages.de.js +21 -0
  146. package/vendor/monaco/vs/nls.messages.es.js +21 -0
  147. package/vendor/monaco/vs/nls.messages.fr.js +19 -0
  148. package/vendor/monaco/vs/nls.messages.it.js +19 -0
  149. package/vendor/monaco/vs/nls.messages.ja.js +21 -0
  150. package/vendor/monaco/vs/nls.messages.ko.js +19 -0
  151. package/vendor/monaco/vs/nls.messages.ru.js +21 -0
  152. package/vendor/monaco/vs/nls.messages.zh-cn.js +21 -0
  153. package/vendor/monaco/vs/nls.messages.zh-tw.js +19 -0
  154. package/vendor/xterm/LICENSE +21 -0
  155. package/vendor/xterm/LICENSE.addon-fit +19 -0
  156. package/vendor/xterm/addon-fit.js +2 -0
  157. package/vendor/xterm/xterm.css +285 -0
  158. package/vendor/xterm/xterm.js +2 -0
@@ -0,0 +1,305 @@
1
+ /**
2
+ * 项目:打开 / 新建 / 校验。只有这里登记过的目录,fs/git/terminal 才允许访问。
3
+ */
4
+ import { mkdir, realpath, stat } from 'node:fs/promises'
5
+ import { isAbsolute, resolve, sep } from 'node:path'
6
+ import { ApiError, badRequest } from './context.js'
7
+ import { debugLog } from './dev.js'
8
+
9
+ async function describeDirectory(rawPath) {
10
+ if (typeof rawPath !== 'string' || rawPath.trim() === '') throw badRequest('path is required')
11
+ if (!isAbsolute(rawPath)) throw badRequest('path must be absolute')
12
+ const absolute = resolve(rawPath)
13
+ let canonical
14
+ try {
15
+ canonical = await realpath(absolute)
16
+ } catch {
17
+ throw new ApiError('not-found', `目录不存在:${absolute}`, 404)
18
+ }
19
+ const info = await stat(canonical)
20
+ if (!info.isDirectory()) throw new ApiError('not-directory', `不是目录:${canonical}`, 400)
21
+ return { canonical, info }
22
+ }
23
+
24
+ export function register(router, env) {
25
+ router.method('project.open', async (body) => {
26
+ const project = await env.projects.open(body.path)
27
+ return project
28
+ })
29
+
30
+ router.method('project.validate', async (body) => {
31
+ const { canonical, info } = await describeDirectory(body.path)
32
+ const isGit = await stat(resolve(canonical, '.git')).then(() => true, () => false)
33
+ return { root: canonical, name: canonical.split(sep).filter(Boolean).at(-1) ?? canonical, isGit, mtimeMs: info.mtimeMs }
34
+ })
35
+
36
+ router.method('project.create', async (body) => {
37
+ if (typeof body.name !== 'string' || body.name.trim() === '') throw badRequest('项目名不能为空')
38
+ const name = body.name.trim()
39
+ if (name.includes('/') || name.includes('\\') || name === '.' || name === '..') throw badRequest('项目名包含非法字符')
40
+ if (typeof body.parent !== 'string' || !isAbsolute(body.parent)) throw badRequest('parent must be an absolute path')
41
+ const { canonical: parentCanonical } = await describeDirectory(body.parent)
42
+ const target = resolve(parentCanonical, name)
43
+ try {
44
+ await mkdir(target)
45
+ } catch (error) {
46
+ if (error !== null && typeof error === 'object' && error.code === 'EEXIST') throw new ApiError('exists', '同名目录已存在', 409)
47
+ throw error
48
+ }
49
+ return await env.projects.open(target)
50
+ })
51
+
52
+ router.method('project.list', async () => ({ projects: env.projects.list() }))
53
+
54
+ router.method('project.forget', async (body) => {
55
+ if (typeof body.root === 'string') env.projects.forget(body.root)
56
+ return { projects: env.projects.list() }
57
+ })
58
+
59
+ /** 在 Host 上下文中惰性取服务(兼容 ctx.get 与属性两种访问方式)。 */
60
+ function serviceOf(name) {
61
+ const ctx = env.ctx
62
+ if (ctx === null || ctx === undefined) return undefined
63
+ try {
64
+ if (typeof ctx.get === 'function') {
65
+ const value = ctx.get(name)
66
+ if (value !== undefined && value !== null) return value
67
+ }
68
+ } catch {
69
+ /* 未注入时 ctx.get 可能抛错,继续尝试属性 */
70
+ }
71
+ try {
72
+ return ctx[name]
73
+ } catch {
74
+ return undefined
75
+ }
76
+ }
77
+
78
+ /** DSH 原生工作区列表(与经典模式同一份 workspaceRegistry 数据)。 */
79
+ router.method('workspaces.list', async () => {
80
+ const registry = serviceOf('workspaceRegistry')
81
+ if (registry === undefined || registry === null || typeof registry.list !== 'function') {
82
+ return { available: false, workspaces: [] }
83
+ }
84
+ let list = []
85
+ try {
86
+ list = registry.list()
87
+ } catch {
88
+ list = []
89
+ }
90
+ const workspaces = (Array.isArray(list) ? list : [])
91
+ .map((item) => {
92
+ const value = item !== null && typeof item === 'object' ? item : {}
93
+ return {
94
+ id: typeof value.id === 'string' ? value.id : '',
95
+ path: typeof value.path === 'string' ? value.path : '',
96
+ title: typeof value.title === 'string' && value.title !== '' ? value.title : '',
97
+ }
98
+ })
99
+ .filter((item) => item.id !== '')
100
+ return { available: true, workspaces }
101
+ })
102
+
103
+ /** 当前工作区的会话列表(标题/时间),复刻经典侧边栏的可见性规则:
104
+ * 工作区 sessionIds 成员 → 排除归档 → 排除 subagent 子会话 → 隐藏 blank(空会话,当前会话除外)
105
+ * → 标题取自 sessionQuery.readTitleSnapshots / sessionTitle → 按更新时间倒序。 */
106
+ router.method('workspaces.sessions', async (body) => {
107
+ try {
108
+ const controller = serviceOf('workspaceController')
109
+ const registry = serviceOf('workspaceRegistry')
110
+ const query = serviceOf('sessionQuery')
111
+ const sessions = serviceOf('sessions')
112
+ const titles = serviceOf('sessionTitle')
113
+
114
+ let views = []
115
+ let archivedSessionIds = []
116
+ let source = 'controller'
117
+
118
+ if (controller !== undefined && controller !== null && typeof controller.follow === 'function') {
119
+ const abort = new AbortController()
120
+ try {
121
+ const iterator = controller.follow(abort.signal)[Symbol.asyncIterator]()
122
+ const first = await iterator.next()
123
+ try {
124
+ await iterator.return?.()
125
+ } catch {
126
+ /* ignore */
127
+ }
128
+ const frame = first?.value
129
+ if (frame !== null && typeof frame === 'object' && (frame.type === 'baseline' || frame.type === 'replace') && frame.value !== null && typeof frame.value === 'object') {
130
+ if (Array.isArray(frame.value.items)) views = frame.value.items
131
+ if (Array.isArray(frame.value.archivedSessionIds)) archivedSessionIds = frame.value.archivedSessionIds
132
+ }
133
+ } catch {
134
+ views = []
135
+ } finally {
136
+ abort.abort()
137
+ }
138
+ }
139
+ if (views.length === 0 && registry !== undefined && registry !== null && typeof registry.list === 'function') {
140
+ source = 'registry'
141
+ try {
142
+ views = registry.list().map((item) => ({
143
+ workspaceId: item.id,
144
+ path: item.path,
145
+ title: item.title,
146
+ sessionIds: Array.isArray(item.sessionIds) ? item.sessionIds : [],
147
+ }))
148
+ } catch {
149
+ views = []
150
+ }
151
+ }
152
+
153
+ const workspaceId = typeof body.workspaceId === 'string' ? body.workspaceId : ''
154
+ const path = typeof body.path === 'string' ? body.path : ''
155
+ const currentSessionId = typeof body.currentSessionId === 'string' ? body.currentSessionId : ''
156
+ const target = views.find((item) => (workspaceId !== '' ? item.workspaceId === workspaceId : item.path === path))
157
+ const membership = target !== undefined && Array.isArray(target.sessionIds) ? target.sessionIds.filter((id) => typeof id === 'string') : []
158
+ const archivedSet = new Set(archivedSessionIds)
159
+
160
+ /* 记录元信息:父子关系(subagent)与创建时间 */
161
+ let records = []
162
+ try {
163
+ if (query !== undefined && query !== null && typeof query.listSessions === 'function') {
164
+ records = await query.listSessions(new AbortController().signal)
165
+ }
166
+ } catch {
167
+ records = []
168
+ }
169
+ const metaById = new Map()
170
+ for (const record of Array.isArray(records) ? records : []) {
171
+ if (record === null || typeof record !== 'object') continue
172
+ const header = record.header !== null && typeof record.header === 'object' ? record.header : null
173
+ const id = typeof header?.id === 'string' ? header.id : typeof record.id === 'string' ? record.id : ''
174
+ if (id === '') continue
175
+ const parentRaw = header?.parentSession ?? record.parentSession ?? record.parentSessionId ?? record.parentId
176
+ const parent = typeof parentRaw === 'string' ? parentRaw : parentRaw !== null && typeof parentRaw === 'object' && typeof parentRaw.id === 'string' ? parentRaw.id : ''
177
+ const depth = typeof header?.delegationDepth === 'number' ? header.delegationDepth : 0
178
+ const createdAt = (() => {
179
+ for (const value of [header?.createdAt, record.updatedAt, record.createdAt]) {
180
+ if (typeof value === 'number' && Number.isFinite(value)) return value
181
+ if (typeof value === 'string') {
182
+ const parsed = Date.parse(value)
183
+ if (Number.isFinite(parsed)) return parsed
184
+ }
185
+ }
186
+ return 0
187
+ })()
188
+ metaById.set(id, { parent, depth, createdAt })
189
+ }
190
+
191
+ /* 标题:批量快照 → 逐个 readTitle → 会话服务兜底;记录哪些是"解析到了" */
192
+ const titleById = new Map()
193
+ const updatedAtById = new Map()
194
+ const titleKnown = new Set()
195
+ let titleError = ''
196
+ let batchResolved = false
197
+ try {
198
+ if (query !== undefined && query !== null && typeof query.readTitleSnapshots === 'function' && membership.length > 0) {
199
+ const snapshots = await query.readTitleSnapshots(membership, new AbortController().signal)
200
+ if (snapshots !== null && typeof snapshots === 'object' && Object.keys(snapshots).length > 0) {
201
+ batchResolved = true
202
+ for (const id of membership) {
203
+ const snapshot = snapshots[id] ?? snapshots.get?.(id)
204
+ if (snapshot === null || snapshot === undefined) continue
205
+ titleKnown.add(id)
206
+ if (typeof snapshot.title === 'string') titleById.set(id, snapshot.title)
207
+ if (typeof snapshot.updatedAt === 'number') updatedAtById.set(id, snapshot.updatedAt)
208
+ }
209
+ }
210
+ }
211
+ } catch (error) {
212
+ titleError = error instanceof Error ? error.message : String(error)
213
+ }
214
+ if (titleKnown.size === 0 && query !== undefined && query !== null && typeof query.readTitle === 'function') {
215
+ for (const id of membership) {
216
+ try {
217
+ const snapshot = await query.readTitle(id, new AbortController().signal)
218
+ if (snapshot === null || snapshot === undefined) continue
219
+ titleKnown.add(id)
220
+ if (typeof snapshot.title === 'string') titleById.set(id, snapshot.title)
221
+ if (typeof snapshot.updatedAt === 'number') updatedAtById.set(id, snapshot.updatedAt)
222
+ } catch (error) {
223
+ titleError = titleError === '' ? (error instanceof Error ? error.message : String(error)) : titleError
224
+ }
225
+ }
226
+ }
227
+ for (const id of membership) {
228
+ if (titleById.get(id) !== undefined && titleById.get(id) !== '') continue
229
+ try {
230
+ if (sessions !== undefined && sessions !== null && typeof sessions.get === 'function') {
231
+ const session = sessions.get(id)
232
+ if (session !== null && session !== undefined) {
233
+ if (titles !== undefined && titles !== null && typeof titles.get === 'function') {
234
+ const snapshot = titles.get(session)
235
+ if (snapshot !== null && snapshot !== undefined && typeof snapshot.title === 'string') titleById.set(id, snapshot.title)
236
+ }
237
+ if (titleById.get(id) === undefined && typeof session.title === 'string') titleById.set(id, session.title)
238
+ if (updatedAtById.get(id) === undefined && typeof session.updatedAt === 'number') updatedAtById.set(id, session.updatedAt)
239
+ }
240
+ }
241
+ } catch {
242
+ /* ignore */
243
+ }
244
+ }
245
+
246
+ const rows = []
247
+ for (const id of membership) {
248
+ if (archivedSet.has(id)) continue
249
+ const meta = metaById.get(id)
250
+ if (meta !== undefined && (meta.parent !== '' || meta.depth > 0)) continue
251
+ const title = titleById.get(id) ?? ''
252
+ const known = titleKnown.has(id)
253
+ // blank 会话不会出现在标题快照结果里:批量解析成功但缺少该项 = blank;当前会话例外
254
+ const blank = (known && title.trim() === '') || (batchResolved && !known)
255
+ if (blank && id !== currentSessionId) continue
256
+ rows.push({
257
+ id,
258
+ title,
259
+ updatedAt: updatedAtById.get(id) ?? meta?.createdAt ?? 0,
260
+ })
261
+ }
262
+ rows.sort((a, b) => b.updatedAt - a.updatedAt)
263
+
264
+ debugLog(`sessions path=${path} membership=${membership.length} visible=${rows.length} titleKnown=${titleKnown.size} batch=${batchResolved} titleError=${titleError === '' ? '-' : titleError.slice(0, 120)} rows=${rows.map((row) => `${row.id.slice(8, 12)}:${String(row.title).slice(0, 10)}`).join('|')}`)
265
+ return {
266
+ available: true,
267
+ source: source + '+membership',
268
+ workspaceId: target !== undefined && typeof target.workspaceId === 'string' ? target.workspaceId : workspaceId,
269
+ title: target !== undefined && typeof target.title === 'string' ? target.title : '',
270
+ path: target !== undefined && typeof target.path === 'string' ? target.path : path,
271
+ sessionIds: membership,
272
+ archivedSessionIds,
273
+ sessions: rows,
274
+ groups: [{ workspaceId: target !== undefined && typeof target.workspaceId === 'string' ? target.workspaceId : workspaceId, title: target !== undefined && typeof target.title === 'string' ? target.title : '', path: target !== undefined && typeof target.path === 'string' ? target.path : path, sessions: rows }],
275
+ debug: { membership: membership.length, visible: rows.length, archived: archivedSessionIds.length, records: Array.isArray(records) ? records.length : 0 },
276
+ }
277
+ } catch (error) {
278
+ return { available: false, error: error instanceof Error ? error.message : String(error), sessions: [], groups: [], sessionIds: [], archivedSessionIds: [] }
279
+ }
280
+ })
281
+
282
+ /** 新建工作区时的默认目录(用于选择器起点)。 */
283
+ router.method('workspaces.defaultDirectory', async () => {
284
+ let directory = ''
285
+ try {
286
+ const working = serviceOf('workingDirectory')
287
+ if (working !== undefined && working !== null && typeof working.defaultDirectory === 'string') directory = working.defaultDirectory
288
+ } catch {
289
+ directory = ''
290
+ }
291
+ if (directory === '') {
292
+ try {
293
+ directory = (await import('node:os')).homedir()
294
+ } catch {
295
+ directory = ''
296
+ }
297
+ }
298
+ return { directory }
299
+ })
300
+
301
+ router.method('project.home', async () => {
302
+ const home = (await import('node:os')).homedir()
303
+ return { home }
304
+ })
305
+ }
package/host/ssh.js ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * SSH:读取 ~/.ssh/config 主机列表,供左侧 SSH 面板一键连接(连接在终端里执行 ssh <alias>)。
3
+ */
4
+ import { readFile } from 'node:fs/promises'
5
+ import { homedir } from 'node:os'
6
+ import { join } from 'node:path'
7
+
8
+ /** 解析 ssh_config 的 Host 块(忽略通配 Host *)。 */
9
+ export function parseSshConfig(text) {
10
+ const hosts = []
11
+ let current = null
12
+ let inBlock = false
13
+ for (const rawLine of text.split('\n')) {
14
+ const line = rawLine.trim()
15
+ if (line === '' || line.startsWith('#')) continue
16
+ const match = /^(\w+)\s+(.*)$/.exec(line)
17
+ if (match === null) continue
18
+ const key = match[1].toLowerCase()
19
+ const value = match[2].split('#')[0].trim()
20
+ if (key === 'host') {
21
+ if (current !== null) hosts.push(current)
22
+ const aliases = value.split(/\s+/).filter((item) => item !== '' && !item.includes('*') && !item.includes('?'))
23
+ current = aliases.length === 0
24
+ ? null
25
+ : { alias: aliases[0], aliases, host: aliases[0], user: '', port: '' }
26
+ inBlock = current !== null
27
+ continue
28
+ }
29
+ if (!inBlock || current === null) continue
30
+ if (key === 'hostname') current.host = value
31
+ else if (key === 'user') current.user = value
32
+ else if (key === 'port') current.port = value
33
+ }
34
+ if (current !== null) hosts.push(current)
35
+ const seen = new Set()
36
+ return hosts.filter((host) => {
37
+ if (seen.has(host.alias)) return false
38
+ seen.add(host.alias)
39
+ return true
40
+ })
41
+ }
42
+
43
+ export function register(router) {
44
+ router.method('ssh.hosts', async () => {
45
+ const candidates = [join(homedir(), '.ssh', 'config'), join(homedir(), '.ssh', 'config.d')]
46
+ let text = ''
47
+ try {
48
+ text = await readFile(candidates[0], 'utf8')
49
+ } catch {
50
+ text = ''
51
+ }
52
+ return { hosts: parseSshConfig(text), configPath: candidates[0] }
53
+ })
54
+ }
@@ -0,0 +1,237 @@
1
+ /**
2
+ * 终端 PTY 的纯工具层:与 Cordis / node-pty 无关,便于单测。
3
+ *
4
+ * 提供:
5
+ * - filteredEnvironment() 过滤敏感环境变量后交给 PTY(KEY/TOKEN/SECRET/…)
6
+ * - ReplayBuffer 按 UTF-8 字节计量的输出回放缓冲(默认 2MB),支持 since 增量续传
7
+ * - clampInteger() cols / rows 规范化
8
+ * - resolveShellPath() 选择登录 shell(显式参数 → provider 默认 → $SHELL → 平台默认)
9
+ *
10
+ * 这些逻辑被 host/terminal.js 使用,并由 scripts/selftest-terminal.mjs 覆盖。
11
+ */
12
+ import { stat } from 'node:fs/promises'
13
+ import { isAbsolute } from 'node:path'
14
+
15
+ /** 传给 PTY 的 TERM;subprocess 会把同一值写入终端环境。 */
16
+ export const TERMINAL_TYPE = 'xterm-256color'
17
+ /** 每个项目根目录下最多保留的终端数。 */
18
+ export const MAX_TERMINALS_PER_ROOT = 8
19
+ /** 每个终端的输出回放缓冲上限(字节,按解码后的 UTF-8 计)。 */
20
+ export const REPLAY_LIMIT_BYTES = 2 * 1024 * 1024
21
+ /** PTY 终止宽限(TERM → KILL)。 */
22
+ export const TERMINAL_GRACE_MS = 5000
23
+ /** 终端创建请求的默认 / 边界尺寸。 */
24
+ export const DEFAULT_COLS = 80
25
+ export const DEFAULT_ROWS = 24
26
+ export const MIN_COLS = 2
27
+ export const MAX_COLS = 1000
28
+ export const MIN_ROWS = 2
29
+ export const MAX_ROWS = 500
30
+
31
+ /**
32
+ * 敏感环境变量名:不传给终端 shell。
33
+ * 按「名字边界」匹配,避免误伤 MONKEY / KEYBOARD 之类普通变量;
34
+ * 同时保留 SSH_AUTH_SOCK、GIT_AUTHOR_* 等交互式 shell 常用变量。
35
+ */
36
+ export const SENSITIVE_ENV_PATTERN = /(^|_)(API_?KEY|KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|PRIVATE_KEY)(_|$)/i
37
+ /** DSH 内部变量不暴露给终端 shell。 */
38
+ export const DSH_ENV_PREFIX = 'DSH_'
39
+
40
+ /**
41
+ * 生成传给 PTY 的环境变量:过滤敏感名与 DSH_*,其余原样保留(HOME/PATH/LANG/SHELL…)。
42
+ * @param {NodeJS.ProcessEnv} [base]
43
+ * @returns {Record<string, string>}
44
+ */
45
+ export function filteredEnvironment(base = process.env) {
46
+ /** @type {Record<string, string>} */
47
+ const env = {}
48
+ for (const [name, value] of Object.entries(base)) {
49
+ if (typeof value !== 'string') continue
50
+ if (name.toUpperCase().startsWith(DSH_ENV_PREFIX)) continue
51
+ if (SENSITIVE_ENV_PATTERN.test(name)) continue
52
+ env[name] = value
53
+ }
54
+ env.TERM = TERMINAL_TYPE
55
+ if (env.COLORTERM === undefined) env.COLORTERM = 'truecolor'
56
+ return env
57
+ }
58
+
59
+ /** 把任意输入规范成 [min, max] 内的整数;非法值返回 fallback。 */
60
+ export function clampInteger(value, fallback, min, max) {
61
+ const number = typeof value === 'number' ? value : Number(value)
62
+ if (!Number.isFinite(number)) return fallback
63
+ return Math.min(max, Math.max(min, Math.trunc(number)))
64
+ }
65
+
66
+ /** 判断一个码元是否为 UTF-16 低代理项(避免按字节截断时切坏字符)。 */
67
+ function isLowSurrogate(code) {
68
+ return code >= 0xdc00 && code <= 0xdfff
69
+ }
70
+
71
+ /** 取字符串尾部不超过 limit 字节的最长片段(按 UTF-8,尽量不切坏字符)。 */
72
+ export function tailByBytes(text, limit) {
73
+ if (limit <= 0) return ''
74
+ if (Buffer.byteLength(text, 'utf8') <= limit) return text
75
+ let low = 0
76
+ let high = text.length
77
+ while (low < high) {
78
+ const middle = (low + high) >> 1
79
+ if (Buffer.byteLength(text.slice(middle), 'utf8') > limit) low = middle + 1
80
+ else high = middle
81
+ }
82
+ if (low > 0 && isLowSurrogate(text.charCodeAt(low))) low += 1
83
+ return text.slice(low)
84
+ }
85
+
86
+ /**
87
+ * 按 UTF-8 字节计量的输出回放缓冲。
88
+ *
89
+ * 语义:
90
+ * - totalBytes 是「历史上写入过的全部文本字节数」,即绝对偏移;
91
+ * - 只保留最新 limitBytes 字节;retainedStart 是保留窗口的绝对起始偏移;
92
+ * - sliceFrom(offset) 返回从绝对偏移 offset 开始的可续传文本;若 offset 已滑出窗口
93
+ * 或落点非法,返回 { lossy: true, text: 完整保留窗口 },调用方应先让客户端 reset。
94
+ */
95
+ export class ReplayBuffer {
96
+ /** @type {{ text: string, bytes: number, start: number }[]} */
97
+ #entries = []
98
+ #bytes = 0
99
+ #totalBytes = 0
100
+
101
+ constructor(limitBytes = REPLAY_LIMIT_BYTES) {
102
+ this.limitBytes = Number.isSafeInteger(limitBytes) && limitBytes > 0 ? limitBytes : REPLAY_LIMIT_BYTES
103
+ }
104
+
105
+ /** 保留窗口的字节数。 */
106
+ get retainedBytes() {
107
+ return this.#bytes
108
+ }
109
+
110
+ /** 保留窗口的绝对起始偏移。 */
111
+ get retainedStart() {
112
+ return this.#entries.length === 0 ? this.#totalBytes : this.#entries[0].start
113
+ }
114
+
115
+ /** 历史写入的总字节数(绝对偏移上界)。 */
116
+ get totalBytes() {
117
+ return this.#totalBytes
118
+ }
119
+
120
+ get size() {
121
+ return this.#entries.length
122
+ }
123
+
124
+ /** 追加一段解码后的输出。 */
125
+ push(text) {
126
+ if (typeof text !== 'string' || text === '') return
127
+ const bytes = Buffer.byteLength(text, 'utf8')
128
+ this.#totalBytes += bytes
129
+ if (bytes >= this.limitBytes) {
130
+ const tail = tailByBytes(text, this.limitBytes)
131
+ const tailBytes = Buffer.byteLength(tail, 'utf8')
132
+ this.#entries = [{ text: tail, bytes: tailBytes, start: this.#totalBytes - tailBytes }]
133
+ this.#bytes = tailBytes
134
+ return
135
+ }
136
+ this.#entries.push({ text, bytes, start: this.#totalBytes - bytes })
137
+ this.#bytes += bytes
138
+ while (this.#bytes > this.limitBytes && this.#entries.length > 1) {
139
+ const removed = this.#entries.shift()
140
+ this.#bytes -= removed.bytes
141
+ }
142
+ }
143
+
144
+ /** 返回保留窗口内的完整文本。 */
145
+ toString() {
146
+ return this.#entries.map((entry) => entry.text).join('')
147
+ }
148
+
149
+ /**
150
+ * 从绝对偏移 offset 续传。
151
+ * @param {number} offset
152
+ * @returns {{ text: string, lossy: boolean }}
153
+ */
154
+ sliceFrom(offset) {
155
+ if (!Number.isSafeInteger(offset) || offset < 0 || offset > this.#totalBytes) {
156
+ return { text: this.toString(), lossy: true }
157
+ }
158
+ if (offset === this.#totalBytes) return { text: '', lossy: false }
159
+ const start = this.retainedStart
160
+ if (offset < start) return { text: this.toString(), lossy: true }
161
+ for (let index = 0; index < this.#entries.length; index += 1) {
162
+ const entry = this.#entries[index]
163
+ if (entry.start !== offset) continue
164
+ return { text: this.#entries.slice(index).map((item) => item.text).join(''), lossy: false }
165
+ }
166
+ // 偏移落在某个 chunk 内部(正常流式下不会出现;仅当调用方计数与写入边界不一致时兜底)。
167
+ for (let index = 0; index < this.#entries.length; index += 1) {
168
+ const entry = this.#entries[index]
169
+ if (offset < entry.start || offset > entry.start + entry.bytes) continue
170
+ const within = Buffer.from(entry.text, 'utf8')
171
+ const tail = within.subarray(offset - entry.start).toString('utf8')
172
+ return { text: [tail, ...this.#entries.slice(index + 1).map((item) => item.text)].join(''), lossy: false }
173
+ }
174
+ return { text: this.toString(), lossy: true }
175
+ }
176
+
177
+ /** 清空(历史绝对偏移保持不变,避免正在重连的客户端错位)。 */
178
+ clear() {
179
+ this.#entries = []
180
+ this.#bytes = 0
181
+ }
182
+ }
183
+
184
+ /** 默认的「文件存在且是普通文件」判断。 */
185
+ async function defaultFileExists(path) {
186
+ try {
187
+ const info = await stat(path)
188
+ return info.isFile()
189
+ } catch {
190
+ return false
191
+ }
192
+ }
193
+
194
+ /**
195
+ * 选择交互式登录 shell:显式参数 → provider 的默认 shell → $SHELL → 平台默认。
196
+ * @param {{ explicit?: unknown, preferred?: unknown, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, exists?: (path: string) => Promise<boolean> }} [options]
197
+ * @returns {Promise<string>}
198
+ */
199
+ export async function resolveShellPath(options = {}) {
200
+ const {
201
+ explicit,
202
+ preferred,
203
+ env = process.env,
204
+ platform = process.platform,
205
+ exists = defaultFileExists,
206
+ } = options
207
+ const usable = async (value) => {
208
+ if (typeof value !== 'string') return false
209
+ const candidate = value.trim()
210
+ if (candidate === '' || !isAbsolute(candidate)) return false
211
+ try {
212
+ return await exists(candidate)
213
+ } catch {
214
+ return false
215
+ }
216
+ }
217
+ if (await usable(explicit)) return explicit.trim()
218
+ if (await usable(preferred)) return preferred.trim()
219
+ if (await usable(env.SHELL)) return env.SHELL.trim()
220
+ if (platform === 'win32') {
221
+ const comspec = typeof env.COMSPEC === 'string' && env.COMSPEC !== '' ? env.COMSPEC : 'powershell.exe'
222
+ return comspec
223
+ }
224
+ return platform === 'darwin' ? '/bin/zsh' : '/bin/bash'
225
+ }
226
+
227
+ /** 登录 shell 的参数:POSIX 用 `-l`,Windows 关闭 logo。 */
228
+ export function terminalShellArgs(platform = process.platform) {
229
+ return platform === 'win32' ? ['-NoLogo'] : ['-l']
230
+ }
231
+
232
+ /** 终端标签标题:shell 可执行文件名(兼容 PATH 分隔符差异,去掉 .exe)。 */
233
+ export function terminalTitleFor(shellPath) {
234
+ const base = String(shellPath ?? '').split(/[\\/]/).filter(Boolean).at(-1) ?? ''
235
+ const trimmed = base.endsWith('.exe') ? base.slice(0, -4) : base
236
+ return trimmed === '' ? 'shell' : trimmed
237
+ }