@lincyaw/dsh 0.0.0-stage → 0.2.0-rc.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.
Files changed (68) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +26 -2
  3. package/build/client.mjs +72 -0
  4. package/build/dist/client.js +1390 -0
  5. package/build/dist/client.json +274 -0
  6. package/build/ensure.mjs +39 -0
  7. package/cordis.patch.yml +26 -0
  8. package/entries/context.mjs +7 -0
  9. package/entries/events.mjs +7 -0
  10. package/entries/load.mjs +11 -0
  11. package/entries/policy.mjs +7 -0
  12. package/entries/routes.mjs +7 -0
  13. package/entries/service.mjs +7 -0
  14. package/entries/skills.mjs +7 -0
  15. package/entries/telemetry.mjs +7 -0
  16. package/entries/tools.mjs +7 -0
  17. package/entries/turns.mjs +7 -0
  18. package/entries/workbench.mjs +9 -0
  19. package/entries/workspaces.mjs +7 -0
  20. package/package.json +108 -3
  21. package/profile.patch.yml +25 -0
  22. package/src/client/RootLayout.tsx +332 -0
  23. package/src/client/agent-requests.ts +64 -0
  24. package/src/client/assistant-setup.tsx +185 -0
  25. package/src/client/author-settings.ts +57 -0
  26. package/src/client/brand.tsx +97 -0
  27. package/src/client/contract.ts +82 -0
  28. package/src/client/document-context.ts +39 -0
  29. package/src/client/dsh.d.ts +155 -0
  30. package/src/client/fallback.tsx +53 -0
  31. package/src/client/frame.css +505 -0
  32. package/src/client/index.tsx +157 -0
  33. package/src/client/launch-target.ts +89 -0
  34. package/src/client/layout.ts +230 -0
  35. package/src/client/mascot.tsx +37 -0
  36. package/src/client/messages.ts +171 -0
  37. package/src/client/navigation-shell.tsx +74 -0
  38. package/src/client/observable.ts +16 -0
  39. package/src/client/paths.ts +17 -0
  40. package/src/client/root-reload.ts +163 -0
  41. package/src/client/runtime-diagnostics.tsx +228 -0
  42. package/src/client/settings-opener.ts +26 -0
  43. package/src/client/source-links.tsx +71 -0
  44. package/src/client/speech-settings.tsx +7 -0
  45. package/src/client/styles.css +45 -0
  46. package/src/client/theme.ts +151 -0
  47. package/src/client/workbench-keys.ts +36 -0
  48. package/src/client/workspace-events.ts +114 -0
  49. package/src/node/bindings.ts +345 -0
  50. package/src/node/context.ts +166 -0
  51. package/src/node/dsh-types.ts +124 -0
  52. package/src/node/fake-collector.ts +46 -0
  53. package/src/node/fake-model.ts +80 -0
  54. package/src/node/launch.ts +176 -0
  55. package/src/node/policy.ts +56 -0
  56. package/src/node/routes.ts +56 -0
  57. package/src/node/server-routes.ts +3 -0
  58. package/src/node/service.ts +131 -0
  59. package/src/node/skills.ts +24 -0
  60. package/src/node/telemetry.ts +126 -0
  61. package/src/node/tools.ts +66 -0
  62. package/src/node/turns.ts +176 -0
  63. package/src/node/watchdog.mjs +36 -0
  64. package/src/node/workbench.ts +88 -0
  65. package/src/node/workspace-events.ts +167 -0
  66. package/src/node/workspaces.ts +10 -0
  67. package/src/workspace-events.ts +31 -0
  68. package/web.patch.yml +161 -0
@@ -0,0 +1,151 @@
1
+ import { color } from '@lincyaw/design'
2
+ import type { ColorName } from '@lincyaw/design'
3
+
4
+ type Pair = { light: string, dark: string }
5
+ const same = (value: string): Pair => ({ light: value, dark: value })
6
+ const pm = (name: ColorName): Pair => same(color(name))
7
+ const mix = (name: ColorName, percent: number, other = 'transparent'): Pair => same(`color-mix(in srgb, ${color(name)} ${percent}%, ${other})`)
8
+
9
+ /** DeepSeek Harness's theme tokens (`--dsw-*`) mapped onto Presmium's design tokens. */
10
+ export const DSH_THEME_TOKENS: Record<string, Pair> = {
11
+ '--dsw-alias-bg-base': pm('bg'),
12
+ '--dsw-alias-bg-document-preview': pm('surface'),
13
+ '--dsw-alias-bg-layer-1': pm('surface'),
14
+ '--dsw-alias-bg-layer-2': pm('surface'),
15
+ '--dsw-alias-bg-layer-3': pm('sunk'),
16
+ '--dsw-alias-bg-mask-1': { light: 'rgba(28, 27, 25, 0.32)', dark: 'rgba(0, 0, 0, 0.55)' },
17
+ '--dsw-alias-bg-mask-drop': { light: 'rgba(247, 245, 240, 0.7)', dark: 'rgba(22, 22, 20, 0.7)' },
18
+ '--dsw-alias-bg-module-platform': pm('sunk'),
19
+ '--dsw-alias-bg-overlay': pm('surface'),
20
+ '--dsw-alias-bg-skeleton': mix('ink', 6),
21
+ '--dsw-alias-border-inverted': same('transparent'),
22
+ '--dsw-alias-border-l1': pm('line'),
23
+ '--dsw-alias-border-l2': pm('line'),
24
+ '--dsw-alias-border-l2-darkmode-thin': pm('line'),
25
+ '--dsw-alias-border-l3': pm('line'),
26
+ '--dsw-alias-border-l4': pm('line-strong'),
27
+ '--dsw-alias-brand-primary': pm('ink'),
28
+ '--dsw-alias-brand-primary-new-colorprimary-new-color': pm('accent'),
29
+ '--dsw-alias-button-contrast-fill': pm('ink'),
30
+ '--dsw-alias-button-elevated-fill': pm('surface'),
31
+ '--dsw-alias-button-floating-fill': pm('surface'),
32
+ '--dsw-alias-button-floating-hover': pm('sunk'),
33
+ '--dsw-alias-button-ghost-active-border': pm('line-strong'),
34
+ '--dsw-alias-button-ghost-active-fill': pm('selected'),
35
+ '--dsw-alias-button-info-fill': { light: '#0f5f5c', dark: '#2c7a74' },
36
+ '--dsw-alias-button-info-hover': { light: '#0b4a47', dark: '#256963' },
37
+ '--dsw-alias-button-primary-fill': pm('ink'),
38
+ '--dsw-alias-button-primary-hover': mix('ink', 85, color('bg')),
39
+ '--dsw-alias-file-diff-added-bg': pm('ok-soft'),
40
+ '--dsw-alias-file-diff-added-gutter': pm('ok-soft'),
41
+ '--dsw-alias-file-diff-added-marker': pm('add'),
42
+ '--dsw-alias-file-diff-deleted-bg': pm('danger-soft'),
43
+ '--dsw-alias-file-diff-deleted-gutter': pm('danger-soft'),
44
+ '--dsw-alias-file-diff-deleted-marker': pm('del'),
45
+ '--dsw-alias-interactive-bg-active': pm('selected'),
46
+ '--dsw-alias-interactive-bg-hover': mix('ink', 6),
47
+ '--dsw-alias-interactive-bg-hover-danger': pm('danger-soft'),
48
+ '--dsw-alias-interactive-bg-hover-solid': pm('sunk'),
49
+ '--dsw-alias-label-caption': pm('muted'),
50
+ '--dsw-alias-label-deep-diving': pm('accent'),
51
+ '--dsw-alias-label-deep-diving-shimmer': pm('muted'),
52
+ '--dsw-alias-label-dimmed': pm('line-strong'),
53
+ '--dsw-alias-label-primary': pm('ink'),
54
+ '--dsw-alias-label-primary-bluish': pm('ink'),
55
+ '--dsw-alias-label-primary-dimmed': pm('ink'),
56
+ '--dsw-alias-label-primary-foreground': pm('bg'),
57
+ '--dsw-alias-label-primary-inverted': pm('bg'),
58
+ '--dsw-alias-label-secondary': pm('muted'),
59
+ '--dsw-alias-label-tertiary': pm('muted'),
60
+ '--dsw-alias-link': pm('accent'),
61
+ '--dsw-alias-markdown-citation': pm('sunk'),
62
+ '--dsw-alias-markdown-code-block': pm('code'),
63
+ '--dsw-alias-markdown-code-block-banner': pm('sunk'),
64
+ '--dsw-alias-menu-icon': pm('ink'),
65
+ '--dsw-alias-onboarding-accent': pm('accent'),
66
+ '--dsw-alias-onboarding-card-fill': pm('surface'),
67
+ '--dsw-alias-onboarding-checkbox-border': pm('line-strong'),
68
+ '--dsw-alias-onboarding-secondary-fill': pm('surface'),
69
+ '--dsw-alias-scrollbar-bg-l1': pm('line'),
70
+ '--dsw-alias-scrollbar-bg-l2': pm('line'),
71
+ '--dsw-alias-scrollbar-hover-l1': pm('line-strong'),
72
+ '--dsw-alias-scrollbar-hover-l2': pm('line-strong'),
73
+ '--dsw-alias-settings-card-fill': pm('surface'),
74
+ '--dsw-alias-settings-card-stroke': pm('line'),
75
+ '--dsw-alias-state-business-primary': pm('accent'),
76
+ '--dsw-alias-state-business-tertiary': pm('accent-soft'),
77
+ '--dsw-alias-state-error-primary': pm('danger'),
78
+ '--dsw-alias-state-error-secondary': pm('danger'),
79
+ '--dsw-alias-state-success-primary': pm('ok'),
80
+ '--dsw-alias-state-success-secondary': pm('ok'),
81
+ '--dsw-alias-state-success-tertiary': pm('ok-soft'),
82
+ '--dsw-alias-state-warn-label': pm('warn'),
83
+ '--dsw-alias-state-warn-primary': pm('warn'),
84
+ '--dsw-alias-state-warn-secondary': pm('warn'),
85
+ '--dsw-alias-state-warn-tertiary': pm('warn-soft'),
86
+ '--dsw-alias-tooltip-bg': { light: '#1c1b19', dark: '#3a3833' },
87
+ '--dsw-alias-turn-trigger-bg': pm('code'),
88
+ '--dsw-alias-turn-trigger-bg-hover': mix('ink', 6),
89
+ '--dsw-specific-sidebar-fill': pm('sunk'),
90
+ '--dsw-specific-sidebar-nav-item-active': pm('selected'),
91
+ '--dsw-specific-sidebar-nav-item-active-accent': pm('accent'),
92
+ '--dsw-specific-sidebar-nav-item-hover': mix('ink', 6),
93
+ '--dsw-specific-bubble': pm('accent-soft'),
94
+ '--dsw-specific-bubble-highlight': pm('selected'),
95
+ '--dsw-specific-input-major': pm('surface'),
96
+ '--dsw-specific-menu': pm('surface'),
97
+ '--dsw-specific-selector': pm('surface'),
98
+ '--dsw-focus-ring-color': pm('accent'),
99
+ }
100
+
101
+ const SANS = 'var(--pm-font-sans)'
102
+ const MONO = 'var(--pm-font-mono)'
103
+ const FONT_SIZES = { text: '--dsh-content-font-size', code: '--dsh-code-font-size', terminal: '--dsh-terminal-font-size' } as const
104
+ const FONT_FAMILIES = { text: '--dsh-font-family-text', code: '--dsh-font-family-code', terminal: '--dsh-font-family-terminal' } as const
105
+
106
+ export class ThemePresenter {
107
+ #applied: string[] = []
108
+ readonly #meta = document.createElement('meta')
109
+
110
+ constructor() {
111
+ this.#meta.name = 'theme-color'
112
+ }
113
+
114
+ apply(snapshot: DshThemeSnapshot): void {
115
+ const scheme = snapshot.active.colorScheme
116
+ const root = document.documentElement
117
+ const body = document.body
118
+ root.style.colorScheme = scheme
119
+ root.setAttribute('data-ds-theme-source', snapshot.preference === 'system' ? 'system' : scheme)
120
+ if (scheme === 'dark') body.setAttribute('data-ds-dark-theme', '')
121
+ else body.removeAttribute('data-ds-dark-theme')
122
+ for (const [role, name] of Object.entries(FONT_SIZES)) body.style.setProperty(name, `${snapshot.fontSizes[role as keyof typeof FONT_SIZES]}px`)
123
+ for (const [role, name] of Object.entries(FONT_FAMILIES)) body.style.setProperty(name, snapshot.fontFamilies[role as keyof typeof FONT_FAMILIES] || (role === 'text' ? SANS : MONO))
124
+ for (const name of this.#applied) body.style.removeProperty(name)
125
+ this.#applied = []
126
+ for (const [name, value] of Object.entries(snapshot.active.tokens)) {
127
+ body.style.setProperty(name, value)
128
+ this.#applied.push(name)
129
+ }
130
+ this.#meta.content = getComputedStyle(body).backgroundColor
131
+ if (!this.#meta.isConnected) document.head.append(this.#meta)
132
+ }
133
+
134
+ dispose(): void {
135
+ const root = document.documentElement
136
+ const body = document.body
137
+ root.style.removeProperty('color-scheme')
138
+ root.removeAttribute('data-ds-theme-source')
139
+ body.removeAttribute('data-ds-dark-theme')
140
+ for (const name of [...Object.values(FONT_SIZES), ...Object.values(FONT_FAMILIES), ...this.#applied]) body.style.removeProperty(name)
141
+ this.#applied = []
142
+ this.#meta.remove()
143
+ }
144
+ }
145
+
146
+ /** Sets the `dark` class of the page to DeepSeek Harness's colour scheme; returns whether it is dark. */
147
+ export function syncDarkClass(snapshot: DshThemeSnapshot): boolean {
148
+ const dark = snapshot.active.colorScheme === 'dark'
149
+ document.documentElement.classList.toggle('dark', dark)
150
+ return dark
151
+ }
@@ -0,0 +1,36 @@
1
+ import type { Workbench } from '@lincyaw/workbench'
2
+ import { CLIENT_MESSAGES } from './messages.ts'
3
+ import type { ClientMessages } from './messages.ts'
4
+
5
+ const apple = /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent)
6
+ const mod = apple ? '⌘' : 'Ctrl', shift = apple ? '⇧' : 'Shift'
7
+ const key = (code: string, ...modifiers: string[]) => ({ code, modifiers })
8
+
9
+ // The reference lists an extension's rows in the order of their ids, so the ids carry the order.
10
+ const ROWS: { id: string, label: keyof ClientMessages['keys'], keys: string[], bindings: [DshShortcutBinding, ...DshShortcutBinding[]] }[] = [
11
+ { id: 'presmium.keys-1-save', label: 'save', keys: [mod, 'S'], bindings: [key('KeyS', 'primary')] },
12
+ { id: 'presmium.keys-2-undo', label: 'undo', keys: [mod, 'Z'], bindings: [key('KeyZ', 'primary')] },
13
+ { id: 'presmium.keys-3-redo', label: 'redo', keys: apple ? [mod, shift, 'Z'] : [mod, 'Y'], bindings: apple ? [key('KeyZ', 'primary', 'shift')] : [key('KeyY', 'primary'), key('KeyZ', 'primary', 'shift')] },
14
+ { id: 'presmium.keys-4-play', label: 'play', keys: ['Space'], bindings: [key('Space')] },
15
+ { id: 'presmium.keys-5-step', label: 'step', keys: ['←', '→'], bindings: [key('ArrowLeft'), key('ArrowRight')] },
16
+ { id: 'presmium.keys-6-overview', label: 'overview', keys: ['O'], bindings: [key('KeyO')] },
17
+ { id: 'presmium.keys-7-fullscreen', label: 'fullscreen', keys: [shift, 'F'], bindings: [key('KeyF', 'shift')] },
18
+ ]
19
+
20
+ /**
21
+ * Lists the workbench's own keys in Harness's shortcut reference (Settings → General → Edit Shortcuts, or Mod+/). They are
22
+ * fixed rows: the workbench and its preview handle the keys themselves, and the rows keep them from editable commands.
23
+ */
24
+ export function registerWorkbenchKeys(ctx: DshClientContext, workbench: Workbench): void {
25
+ ctx.effect(() => {
26
+ const disposers = ROWS.flatMap(({ label, ...row }) => {
27
+ try {
28
+ return [ctx.shortcuts.registerFixed({ ...row, label: () => CLIENT_MESSAGES[workbench.state.locale].keys[label], group: 'application' })]
29
+ } catch (error) {
30
+ console.warn(`[presmium] the shortcut reference did not take ${row.id}`, error)
31
+ return []
32
+ }
33
+ })
34
+ return () => { for (const dispose of disposers) dispose() }
35
+ }, 'presmium: the workbench keys in the shortcut reference')
36
+ }
@@ -0,0 +1,114 @@
1
+ import { WebSocket } from 'partysocket'
2
+ import type { WorkspaceSubscription } from '@lincyaw/workbench'
3
+ import type { AuthoringClientAcknowledgement, AuthoringClientEvent, AuthoringClientReport } from '@lincyaw/kernel'
4
+ import { AUTHORING_CLIENT_REQUEST_TIMEOUT_MS } from '@lincyaw/kernel'
5
+ import { WORKSPACE_EVENTS_PATH, workspaceEventMessage } from '../workspace-events.ts'
6
+ import type { WorkspaceEventTopic, WorkspaceEventCommand } from '../workspace-events.ts'
7
+
8
+ const TOPICS: Readonly<Record<string, WorkspaceEventTopic>> = {
9
+ document: 'document', diagnostics: 'diagnostics', review: 'review', revision: 'ir', job: 'jobs', 'viewer-site': 'viewer-site', 'viewer-control': 'viewer-control', approvals: 'approvals',
10
+ }
11
+
12
+ /** Each live reader owns one logical subscription; the library owns physical reconnect timing. */
13
+ export function workspaceSubscriptions(ctx: Pick<DshClientContext, 'effect'>): WorkspaceSubscription {
14
+ type Subscription = { id?: string, request: Extract<WorkspaceEventCommand, { type: 'subscribe' }>, received(data: string, event?: string): void, failed?(): void, ready?(): void, pending?: Map<number, { resolve(): void, reject(error: Error): void, timer: ReturnType<typeof setTimeout> }> }
15
+ const readers = new Set<Subscription>()
16
+ const ids = new Map<string, Subscription>()
17
+ const url = new URL(WORKSPACE_EVENTS_PATH.slice(1), document.baseURI)
18
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
19
+ const socket = new WebSocket(url.href, [], { startClosed: true, maxEnqueuedMessages: 0 })
20
+ let closed = false
21
+ let serial = 0
22
+ const subscribe = (reader: Subscription) => {
23
+ const id = String(++serial)
24
+ reader.id = id
25
+ ids.set(id, reader)
26
+ socket.send(JSON.stringify({ ...reader.request, id }))
27
+ }
28
+ const opened = () => {
29
+ if (closed) return
30
+ ids.clear()
31
+ for (const reader of readers) subscribe(reader)
32
+ }
33
+ const disconnected = () => {
34
+ ids.clear()
35
+ for (const reader of readers) { reader.id = undefined; rejectPublications(reader); reader.failed?.() }
36
+ }
37
+ const rejectPublications = (reader: Subscription) => { for (const pending of reader.pending?.values() ?? []) { clearTimeout(pending.timer); pending.reject(new Error('client-transport-disconnected')) }; reader.pending?.clear() }
38
+ const message = (event: MessageEvent) => {
39
+ let raw: unknown
40
+ try { raw = JSON.parse(event.data) } catch { return }
41
+ const parsed = workspaceEventMessage.safeParse(raw)
42
+ if (!parsed.success) return
43
+ const value = parsed.data
44
+ const reader = ids.get(value.id)
45
+ if (!reader || !readers.has(reader)) return
46
+ if (value.type === 'published') {
47
+ const pending = reader.pending?.get(value.sequence)
48
+ reader.pending?.delete(value.sequence)
49
+ if (pending) clearTimeout(pending.timer)
50
+ if (value.ok) pending?.resolve(); else pending?.reject(new Error('client-publication-refused'))
51
+ } else if (value.type === 'ready') reader.ready?.()
52
+ else if (value.type === 'error') { rejectPublications(reader); reader.failed?.() }
53
+ else {
54
+ const topic = reader.request.topic
55
+ const named = topic === 'viewer-site' ? ['ir', 'reload'] : topic === 'viewer-control' ? ['ready', 'approval', 'settled', 'go', 'session'] : topic === 'approvals' ? ['approval', 'settled'] : undefined
56
+ if (named && (!value.event || !named.includes(value.event))) return
57
+ reader.received(value.data, value.event)
58
+ }
59
+ }
60
+ socket.addEventListener('open', opened)
61
+ socket.addEventListener('close', disconnected)
62
+ socket.addEventListener('message', message)
63
+ ctx.effect(() => () => {
64
+ closed = true
65
+ for (const reader of readers) rejectPublications(reader)
66
+ readers.clear(); ids.clear()
67
+ socket.removeEventListener('open', opened)
68
+ socket.removeEventListener('close', disconnected)
69
+ socket.removeEventListener('message', message)
70
+ socket.close()
71
+ }, 'presmium: workspace subscriptions')
72
+ const add = (reader: Subscription) => {
73
+ if (!closed) {
74
+ readers.add(reader)
75
+ if (socket.readyState === WebSocket.OPEN) subscribe(reader)
76
+ else if (readers.size === 1) socket.reconnect()
77
+ }
78
+ return () => {
79
+ if (!readers.delete(reader)) return
80
+ rejectPublications(reader)
81
+ if (reader.id) {
82
+ ids.delete(reader.id)
83
+ if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: 'unsubscribe', id: reader.id }))
84
+ }
85
+ if (readers.size === 0) socket.close()
86
+ }
87
+ }
88
+ const subscription: WorkspaceSubscription = (address, name, received, failed, ready) => {
89
+ const topic = TOPICS[name]
90
+ const query = new URL(address).searchParams
91
+ const document = query.get('document')
92
+ if (!topic || topic === 'authoring-client' || document === null) throw new Error('Invalid workspace event subscription.')
93
+ const request: Subscription['request'] = topic === 'viewer-site'
94
+ ? { type: 'subscribe', id: '', topic, document, app: query.get('app') ?? '', lang: query.get('lang') ?? '', form: query.get('form') === 'slides' ? 'slides' : 'web' }
95
+ : topic === 'viewer-control' ? { type: 'subscribe', id: '', topic, document, window: query.get('window') ?? '' }
96
+ : { type: 'subscribe', id: '', topic, document }
97
+ const reader: Subscription = { request, received, failed, ready }
98
+ return add(reader)
99
+ }
100
+ subscription.openAuthoringClient = (document, open, received, failed) => {
101
+ const reader: Subscription = { request: { type: 'subscribe', id: '', topic: 'authoring-client', document, ...open }, pending: new Map(), failed,
102
+ received(raw) { let event: AuthoringClientEvent; try { event = JSON.parse(raw) } catch { return }; if (event.type === 'connected') rejectPublications(reader); received(event) } }
103
+ const close = add(reader)
104
+ const publish = (kind: 'state' | 'ack', connectionId: string, report: AuthoringClientReport | AuthoringClientAcknowledgement) => new Promise<void>((resolve, reject) => {
105
+ if (closed || !readers.has(reader) || !reader.id || socket.readyState !== WebSocket.OPEN || reader.pending!.has(report.sequence)) { reject(new Error('client-transport-disconnected')); return }
106
+ // A lost publication receipt must release the queue without authorizing a repeated action.
107
+ const timer = setTimeout(() => { reader.pending!.delete(report.sequence); reject(new Error('client-publication-timeout')) }, AUTHORING_CLIENT_REQUEST_TIMEOUT_MS / 2)
108
+ reader.pending!.set(report.sequence, { resolve, reject, timer })
109
+ socket.send(JSON.stringify({ type: 'authoring-client-publish', id: reader.id, connectionId, sequence: report.sequence, kind, value: report }))
110
+ })
111
+ return { close, publish: (connectionId, report) => publish('state', connectionId, report), acknowledge: (connectionId, report) => publish('ack', connectionId, report) }
112
+ }
113
+ return subscription
114
+ }
@@ -0,0 +1,345 @@
1
+ import { realpathSync } from 'node:fs'
2
+ import { relative, resolve, sep } from 'node:path'
3
+ import { connectPresmiumMcp } from '@lincyaw/mcp/client'
4
+ import { errorMessage, isRecord } from '@lincyaw/kernel'
5
+ import type { DiagnosticSnapshot, DocumentSummary } from '@lincyaw/kernel'
6
+ import type { ListedTool, PresmiumMcpClient } from '@lincyaw/mcp/client'
7
+ import type { DshAgent, DshContext, DshLogger } from './dsh-types.ts'
8
+ import { SERVER_ROUTES } from './server-routes.ts'
9
+ import type { WorkspaceServerClient } from './service.ts'
10
+
11
+ /** The document an agent session works on: an MCP client with a session token for it, its tools and its diagnostics. */
12
+ export interface DocumentBinding {
13
+ agentSessionId: string
14
+ document: DocumentSummary
15
+ dir: string
16
+ client: PresmiumMcpClient
17
+ readonly tools: ListedTool[]
18
+ toolChanges: BindingTools
19
+ diagnostics: BindingDiagnostics
20
+ }
21
+
22
+ interface SelectionListener {
23
+ before(): Promise<void>
24
+ after(): Promise<void>
25
+ }
26
+
27
+ /** Refreshes the tool list on MCP invalidations without replacing its document-bound client. */
28
+ export class BindingTools {
29
+ snapshot: ListedTool[] = []
30
+ error: string | undefined
31
+ readonly #client: PresmiumMcpClient
32
+ readonly #log: DshLogger
33
+ readonly #listeners = new Set<() => void>()
34
+ #signature = ''
35
+ #unsubscribe: (() => Promise<void>) | undefined
36
+ #pending: Promise<void> | undefined
37
+ #dirty = false
38
+ #closed = false
39
+
40
+ constructor(client: PresmiumMcpClient, log: DshLogger) {
41
+ this.#client = client
42
+ this.#log = log
43
+ }
44
+
45
+ async start(): Promise<void> {
46
+ this.#unsubscribe = await this.#client.subscribeTools(() => { void this.refresh() })
47
+ await this.refresh()
48
+ if (this.error) throw new Error(this.error)
49
+ }
50
+
51
+ watch(listener: () => void): () => void {
52
+ this.#listeners.add(listener)
53
+ return () => { this.#listeners.delete(listener) }
54
+ }
55
+
56
+ refresh(): Promise<void> {
57
+ if (this.#closed) return Promise.resolve()
58
+ this.#dirty = true
59
+ if (!this.#pending) {
60
+ this.#pending = this.#read().finally(() => {
61
+ this.#pending = undefined
62
+ if (this.#dirty && !this.#closed) return this.refresh()
63
+ })
64
+ }
65
+ return this.#pending
66
+ }
67
+
68
+ async #read(): Promise<void> {
69
+ while (this.#dirty && !this.#closed) {
70
+ this.#dirty = false
71
+ try {
72
+ const tools = await this.#client.tools()
73
+ if (this.#closed || this.#dirty) continue
74
+ this.error = undefined
75
+ const signature = JSON.stringify(tools)
76
+ if (signature === this.#signature) continue
77
+ this.#signature = signature
78
+ this.snapshot = tools
79
+ for (const listener of this.#listeners) listener()
80
+ } catch (error) {
81
+ if (!this.#closed) {
82
+ this.error = errorMessage(error)
83
+ this.#log.warn(`presmium-tools: the tool list did not refresh: ${this.error}`)
84
+ }
85
+ }
86
+ }
87
+ }
88
+
89
+ async close(): Promise<void> {
90
+ this.#closed = true
91
+ this.#dirty = false
92
+ this.#listeners.clear()
93
+ await this.#unsubscribe?.()
94
+ }
95
+ }
96
+
97
+ /** One current resource snapshot, shared by notifications and the next model step. */
98
+ export class BindingDiagnostics {
99
+ snapshot: DiagnosticSnapshot | undefined
100
+ error: string | undefined
101
+ readonly #client: PresmiumMcpClient
102
+ readonly #uri: string
103
+ #unsubscribe: (() => Promise<void>) | undefined
104
+ #pending: Promise<void> | undefined
105
+ #dirty = false
106
+ #closed = false
107
+
108
+ constructor(client: PresmiumMcpClient, document: string) {
109
+ this.#client = client
110
+ this.#uri = `presmium://diagnostics?document=${encodeURIComponent(document)}`
111
+ }
112
+
113
+ async start(): Promise<void> {
114
+ this.#unsubscribe = await this.#client.subscribe(this.#uri, () => { void this.refresh() })
115
+ await this.refresh()
116
+ }
117
+
118
+ refresh(): Promise<void> {
119
+ if (this.#closed) return Promise.resolve()
120
+ this.#dirty = true
121
+ if (!this.#pending) {
122
+ const pending = this.#read().finally(() => {
123
+ this.#pending = undefined
124
+ if (this.#dirty && !this.#closed) return this.refresh()
125
+ })
126
+ this.#pending = pending
127
+ }
128
+ return this.#pending
129
+ }
130
+
131
+ async #read(): Promise<void> {
132
+ while (this.#dirty && !this.#closed) {
133
+ this.#dirty = false
134
+ try {
135
+ const value = await this.#client.read(this.#uri)
136
+ if (!isRecord(value) || typeof value.version !== 'number' || !Array.isArray(value.diagnostics)) throw new Error('the diagnostics resource returned an invalid snapshot')
137
+ if (this.#closed) return
138
+ this.snapshot = value as unknown as DiagnosticSnapshot
139
+ this.error = undefined
140
+ } catch (error) {
141
+ if (!this.#closed) this.error = errorMessage(error)
142
+ }
143
+ }
144
+ }
145
+
146
+ async close(): Promise<void> {
147
+ this.#closed = true
148
+ this.#dirty = false
149
+ await this.#unsubscribe?.()
150
+ }
151
+ }
152
+
153
+ async function closeBinding(binding: DocumentBinding): Promise<void> {
154
+ try {
155
+ await Promise.all([binding.diagnostics.close(), binding.toolChanges.close()])
156
+ } finally {
157
+ await binding.client.close()
158
+ }
159
+ }
160
+
161
+ function realPath(path: string): string {
162
+ try {
163
+ return realpathSync(path)
164
+ } catch {
165
+ return resolve(path)
166
+ }
167
+ }
168
+
169
+ function documentAt(root: string, dir: string, documents: DocumentSummary[]): DocumentSummary | undefined {
170
+ const rel = relative(realPath(root), realPath(dir)).split(sep).join('/')
171
+ if (rel.startsWith('..')) return undefined
172
+ return documents.find(doc => doc.dir === rel)
173
+ }
174
+
175
+ /**
176
+ * Binds each agent session to one document of the workspace: the one the workbench selected for it, or else the
177
+ * document its working directory is in. Selecting no document (`null`) leaves the session unbound.
178
+ */
179
+ export class DocumentBindings {
180
+ readonly #ctx: DshContext
181
+ readonly #server: WorkspaceServerClient
182
+ readonly #log: DshLogger
183
+ /** Each agent session's request to bind, kept once it is bound. */
184
+ readonly #bindRequests = new Map<string, Promise<DocumentBinding | undefined>>()
185
+ readonly #bound = new Map<string, DocumentBinding>()
186
+ readonly #failures = new Map<string, string>()
187
+ readonly #syncing = new Map<string, Promise<DocumentBinding | undefined>>()
188
+ readonly #selectionVersions = new Map<string, number>()
189
+ readonly #selectionListeners = new Map<string, SelectionListener>()
190
+ readonly #selected = new Map<string, string | null>()
191
+ #closed = false
192
+
193
+ constructor(ctx: DshContext, server: WorkspaceServerClient, log: DshLogger) {
194
+ this.#ctx = ctx
195
+ this.#server = server
196
+ this.#log = log
197
+ }
198
+
199
+ workingDirectoryOf(agent: DshAgent): string {
200
+ const dirs = this.#ctx.get('workingDirectory') as { get(session: unknown): string } | undefined
201
+ try {
202
+ return dirs?.get(agent.session) ?? agent.session.header?.cwd ?? process.cwd()
203
+ } catch {
204
+ return agent.session.header?.cwd ?? process.cwd()
205
+ }
206
+ }
207
+
208
+ bind(agent: DshAgent): Promise<DocumentBinding | undefined> {
209
+ const id = agent.session.id
210
+ let known = this.#bindRequests.get(id)
211
+ if (!known) {
212
+ const current = () => !this.#closed && this.#bindRequests.get(id) === known
213
+ known = this.#bind(agent, current).catch((error: unknown) => {
214
+ if (!current()) return undefined
215
+ const message = errorMessage(error)
216
+ this.#log.warn(`presmium-service: the session ${id} has no Presmium tools: ${message}`)
217
+ this.#failures.set(id, message)
218
+ this.#bindRequests.delete(id)
219
+ return undefined
220
+ })
221
+ this.#bindRequests.set(id, known)
222
+ }
223
+ return known
224
+ }
225
+
226
+ async #bind(agent: DshAgent, current: () => boolean): Promise<DocumentBinding | undefined> {
227
+ const { root } = await this.#server.ready
228
+ const id = agent.session.id
229
+ const documents = await this.#server.readDocuments()
230
+ const selected = this.#selected.get(id)
231
+ const document = selected === undefined ? documentAt(root, this.workingDirectoryOf(agent), documents) : documents.find(doc => doc.dir === selected)
232
+ if (!current()) return undefined
233
+ if (!document) {
234
+ this.#failures.delete(id)
235
+ return undefined
236
+ }
237
+ const grant = await this.#server.requestJson<{ url: string, token: string }>(`${SERVER_ROUTES}tokens`, { body: { document: document.dir, session: id } })
238
+ const client = await connectPresmiumMcp(grant.url, grant.token)
239
+ const diagnostics = new BindingDiagnostics(client, document.dir)
240
+ const toolChanges = new BindingTools(client, this.#log)
241
+ try {
242
+ const binding: DocumentBinding = { agentSessionId: id, document, dir: resolve(root, document.dir), client, get tools() { return toolChanges.snapshot }, toolChanges, diagnostics }
243
+ await toolChanges.start()
244
+ await diagnostics.start()
245
+ if (current()) {
246
+ this.#failures.delete(id)
247
+ this.#bound.set(id, binding)
248
+ return binding
249
+ }
250
+ } catch (error) {
251
+ await Promise.all([diagnostics.close(), toolChanges.close()]).catch(() => undefined)
252
+ await client.close().catch(() => undefined)
253
+ throw error
254
+ }
255
+ await Promise.all([diagnostics.close(), toolChanges.close()]).finally(() => client.close())
256
+ return undefined
257
+ }
258
+
259
+ async selectDocument(agentSessionId: string, document: string | null): Promise<void> {
260
+ const version = (this.#selectionVersions.get(agentSessionId) ?? 0) + 1
261
+ this.#selectionVersions.set(agentSessionId, version)
262
+ if (document !== null) {
263
+ let documents = await this.#server.readDocuments()
264
+ if (!documents.some(doc => doc.dir === document)) {
265
+ this.#server.invalidateDocuments()
266
+ documents = await this.#server.readDocuments()
267
+ }
268
+ if (this.#selectionVersions.get(agentSessionId) !== version) return
269
+ if (!documents.some(doc => doc.dir === document)) throw new Error('Unknown Presmium document')
270
+ }
271
+ const listener = this.#selectionListeners.get(agentSessionId)
272
+ await listener?.before()
273
+ if (this.#selectionVersions.get(agentSessionId) !== version) return
274
+ this.#selected.set(agentSessionId, document)
275
+ await listener?.after()
276
+ }
277
+
278
+ watchSelection(agentSessionId: string, listener: SelectionListener): () => void {
279
+ this.#selectionListeners.set(agentSessionId, listener)
280
+ return () => { if (this.#selectionListeners.get(agentSessionId) === listener) this.#selectionListeners.delete(agentSessionId) }
281
+ }
282
+
283
+ /** Binds the session again when the selection changed or its diagnostics failed; concurrent calls share one. */
284
+ syncBinding(agent: DshAgent): Promise<DocumentBinding | undefined> {
285
+ const id = agent.session.id
286
+ let pending = this.#syncing.get(id)
287
+ if (!pending) {
288
+ pending = this.#syncBinding(agent).finally(() => this.#syncing.delete(id))
289
+ this.#syncing.set(id, pending)
290
+ }
291
+ return pending
292
+ }
293
+
294
+ async #syncBinding(agent: DshAgent): Promise<DocumentBinding | undefined> {
295
+ const id = agent.session.id
296
+ const selected = this.#selected.get(id)
297
+ const current = await this.bind(agent)
298
+ if (!current?.diagnostics.error && (selected === undefined || current?.document.dir === selected || (!current && selected === null))) return current
299
+ await this.unbind(id, false)
300
+ return this.bind(agent)
301
+ }
302
+
303
+ bindingOf(agent: DshAgent | undefined): DocumentBinding | undefined {
304
+ return agent ? this.#bound.get(agent.session.id) : undefined
305
+ }
306
+
307
+ /** The document the session works on now, if it is bound to one. */
308
+ documentOf(agentSessionId: string): string | undefined {
309
+ return this.#bound.get(agentSessionId)?.document.dir
310
+ }
311
+
312
+ failureOf(agent: DshAgent): string | undefined {
313
+ return this.#failures.get(agent.session.id)
314
+ }
315
+
316
+ /** Closes the session's binding and revokes its session token; `forget` drops its selection too. */
317
+ async unbind(agentSessionId: string, forget = true): Promise<void> {
318
+ if (forget) {
319
+ this.#selectionVersions.delete(agentSessionId)
320
+ this.#selected.delete(agentSessionId)
321
+ this.#selectionListeners.delete(agentSessionId)
322
+ }
323
+ const binding = this.#bound.get(agentSessionId)
324
+ const request = this.#bindRequests.get(agentSessionId)
325
+ this.#bound.delete(agentSessionId)
326
+ this.#bindRequests.delete(agentSessionId)
327
+ this.#failures.delete(agentSessionId)
328
+ if (binding) await closeBinding(binding).catch(error => this.#log.warn(`presmium-service: closing session ${agentSessionId}: ${errorMessage(error)}`))
329
+ else await request
330
+ if (!binding && !request) return
331
+ await this.#server.request(`${SERVER_ROUTES}tokens`, { method: 'DELETE', body: { session: agentSessionId } }).then(r => r.body?.cancel(), () => undefined)
332
+ }
333
+
334
+ async close(): Promise<void> {
335
+ this.#closed = true
336
+ for (const binding of this.#bound.values()) await closeBinding(binding).catch(error => this.#log.warn(`presmium-service: closing session ${binding.agentSessionId}: ${errorMessage(error)}`))
337
+ this.#bound.clear()
338
+ await Promise.all(this.#bindRequests.values())
339
+ this.#bindRequests.clear()
340
+ this.#failures.clear()
341
+ this.#selected.clear()
342
+ this.#selectionVersions.clear()
343
+ this.#selectionListeners.clear()
344
+ }
345
+ }