@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.
- package/LICENSE +202 -0
- package/README.md +26 -2
- package/build/client.mjs +72 -0
- package/build/dist/client.js +1390 -0
- package/build/dist/client.json +274 -0
- package/build/ensure.mjs +39 -0
- package/cordis.patch.yml +26 -0
- package/entries/context.mjs +7 -0
- package/entries/events.mjs +7 -0
- package/entries/load.mjs +11 -0
- package/entries/policy.mjs +7 -0
- package/entries/routes.mjs +7 -0
- package/entries/service.mjs +7 -0
- package/entries/skills.mjs +7 -0
- package/entries/telemetry.mjs +7 -0
- package/entries/tools.mjs +7 -0
- package/entries/turns.mjs +7 -0
- package/entries/workbench.mjs +9 -0
- package/entries/workspaces.mjs +7 -0
- package/package.json +108 -3
- package/profile.patch.yml +25 -0
- package/src/client/RootLayout.tsx +332 -0
- package/src/client/agent-requests.ts +64 -0
- package/src/client/assistant-setup.tsx +185 -0
- package/src/client/author-settings.ts +57 -0
- package/src/client/brand.tsx +97 -0
- package/src/client/contract.ts +82 -0
- package/src/client/document-context.ts +39 -0
- package/src/client/dsh.d.ts +155 -0
- package/src/client/fallback.tsx +53 -0
- package/src/client/frame.css +505 -0
- package/src/client/index.tsx +157 -0
- package/src/client/launch-target.ts +89 -0
- package/src/client/layout.ts +230 -0
- package/src/client/mascot.tsx +37 -0
- package/src/client/messages.ts +171 -0
- package/src/client/navigation-shell.tsx +74 -0
- package/src/client/observable.ts +16 -0
- package/src/client/paths.ts +17 -0
- package/src/client/root-reload.ts +163 -0
- package/src/client/runtime-diagnostics.tsx +228 -0
- package/src/client/settings-opener.ts +26 -0
- package/src/client/source-links.tsx +71 -0
- package/src/client/speech-settings.tsx +7 -0
- package/src/client/styles.css +45 -0
- package/src/client/theme.ts +151 -0
- package/src/client/workbench-keys.ts +36 -0
- package/src/client/workspace-events.ts +114 -0
- package/src/node/bindings.ts +345 -0
- package/src/node/context.ts +166 -0
- package/src/node/dsh-types.ts +124 -0
- package/src/node/fake-collector.ts +46 -0
- package/src/node/fake-model.ts +80 -0
- package/src/node/launch.ts +176 -0
- package/src/node/policy.ts +56 -0
- package/src/node/routes.ts +56 -0
- package/src/node/server-routes.ts +3 -0
- package/src/node/service.ts +131 -0
- package/src/node/skills.ts +24 -0
- package/src/node/telemetry.ts +126 -0
- package/src/node/tools.ts +66 -0
- package/src/node/turns.ts +176 -0
- package/src/node/watchdog.mjs +36 -0
- package/src/node/workbench.ts +88 -0
- package/src/node/workspace-events.ts +167 -0
- package/src/node/workspaces.ts +10 -0
- package/src/workspace-events.ts +31 -0
- 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
|
+
}
|