dsh-workspace-plus 0.4.0 → 0.6.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.
@@ -0,0 +1,67 @@
1
+ <#
2
+ .SYNOPSIS
3
+ dsh-notice protocol launcher: turns a click on a notification into one local
4
+ HTTP request against the running Harness.
5
+
6
+ .DESCRIPTION
7
+ Windows resolves `dsh-notice:open?s=<session>` to the command line this plugin
8
+ wrote under `HKCU\Software\Classes\dsh-notice\shell\open\command`, which runs
9
+ this script. The script does exactly one thing: forwards the activated URL to
10
+ the plugin's own loopback route, which owns the rest of the click (recording
11
+ the conversation, bringing the desktop window forward, and telling the
12
+ browser half to select it).
13
+
14
+ Why a separate process at all: a URL protocol registered by an unpackaged
15
+ application has to name an executable, and PowerShell is the one interpreter
16
+ guaranteed to exist on Windows. The registry command passes `-WindowStyle
17
+ Hidden`, so the console this would otherwise open never appears.
18
+
19
+ When the host is not listening -- DSH was closed, or is restarting -- the
20
+ request fails and the script falls back to activating `dsh://open`, which
21
+ brings the desktop application forward if it is still alive. A click should
22
+ never do nothing at all.
23
+
24
+ .NOTES
25
+ `System.Net.WebClient` is used rather than `Invoke-WebRequest` because it can
26
+ be told to ignore the system proxy outright: a proxy configured for the
27
+ machine must not be asked to resolve a 127.0.0.1 URL.
28
+ #>
29
+ param(
30
+ [Parameter(Position = 0)][string]$Url = '',
31
+ [string]$Scheme = 'dsh-notice',
32
+ [int]$Port = 0,
33
+ [string]$Token = ''
34
+ )
35
+
36
+ $ErrorActionPreference = 'Stop'
37
+
38
+ if ($Port -le 0 -or $Token -eq '') { exit 2 }
39
+
40
+ # The whole activated URL travels as one query value; the host parses the
41
+ # session id out of it, so this script never has to agree with the host about
42
+ # the URL's internal shape.
43
+ $endpoint = "http://127.0.0.1:$Port/dsh-notice/api/open?token=$([uri]::EscapeDataString($Token))&url=$([uri]::EscapeDataString($Url))"
44
+
45
+ $delivered = $false
46
+ $client = $null
47
+ try {
48
+ $client = New-Object System.Net.WebClient
49
+ $client.Proxy = $null
50
+ $null = $client.DownloadString($endpoint)
51
+ $delivered = $true
52
+ } catch {
53
+ $delivered = $false
54
+ } finally {
55
+ if ($null -ne $client) { $client.Dispose() }
56
+ }
57
+
58
+ if ($delivered) { exit 0 }
59
+
60
+ # The host is gone. Bring the desktop window forward anyway, so a click on an
61
+ # older notification still lands on the application it is about.
62
+ try {
63
+ Start-Process -FilePath 'dsh://open' -ErrorAction Stop | Out-Null
64
+ exit 0
65
+ } catch {
66
+ exit 1
67
+ }
@@ -0,0 +1,313 @@
1
+ /**
2
+ * dsh-workspace-plus/notice — notification wording (ported from dsh-notice).
3
+ *
4
+ * Everything here is pure: one event in, one fully-formed notification
5
+ * descriptor out, or `null` when the event must not be announced at all. Keeping
6
+ * the wording out of the plugin body is what makes the interesting cases — an
7
+ * aborted turn, an error turn, a question with no text — testable without a
8
+ * running Harness.
9
+ *
10
+ * A descriptor is deliberately three short lines rather than one sentence. A
11
+ * Windows toast renders the first line bold and wraps the rest, and the user's
12
+ * question is always "which conversation, and what does it want", so the type
13
+ * leads, the conversation follows, and the detail lands last.
14
+ *
15
+ * @module dsh-workspace-plus/notice/format
16
+ */
17
+
18
+ /**
19
+ * Operating-system command escape sequences, including unterminated tails.
20
+ * Session text reaches a toast verbatim, and a tool name or model message that
21
+ * carries terminal escapes would render as invisible junk in the notification.
22
+ */
23
+ const OSC_SEQUENCE = /(?:\u001B\]|\u009D)(?:(?!\u0007|\u001B\\)[\s\S])*(?:\u0007|\u001B\\|$)/gu
24
+
25
+ /** CSI escapes such as SGR color codes. */
26
+ const CSI_SEQUENCE = /(?:\u001B\[|\u009B)[0-?]*[ -/]*[@-~]/gu
27
+
28
+ /** Remaining two-byte ESC control sequences. */
29
+ const ESC_SEQUENCE = /\u001B[@-_]/gu
30
+
31
+ /** Non-whitespace C0/C1 control characters. */
32
+ const CONTROL_CHARACTER = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/gu
33
+
34
+ /**
35
+ * Directional and invisible controls. They can reorder or hide the visible text
36
+ * of a notification, so a title or model message carrying them is not allowed to
37
+ * make the toast say something other than what it appears to say.
38
+ */
39
+ const DIRECTIONAL_CONTROL = /[\u200B\u200E\u200F\u202A-\u202E\u2060-\u2064\u2066-\u206F\uFEFF]/gu
40
+
41
+ /** Byte budget of the bold first line. */
42
+ export const TITLE_BYTES = 120
43
+
44
+ /** Byte budget of the trailing detail line. */
45
+ export const DETAIL_BYTES = 320
46
+
47
+ /** Byte budget of the conversation line. */
48
+ export const SESSION_BYTES = 160
49
+
50
+ /**
51
+ * Reduce untrusted text to one clean, single-line string.
52
+ *
53
+ * @param input - any text (non-strings become an empty string).
54
+ * @returns the cleaned text.
55
+ */
56
+ export function cleanText(input) {
57
+ if (typeof input !== 'string') return ''
58
+ return input
59
+ .replace(OSC_SEQUENCE, '')
60
+ .replace(CSI_SEQUENCE, '')
61
+ .replace(ESC_SEQUENCE, '')
62
+ .replace(CONTROL_CHARACTER, '')
63
+ .replace(DIRECTIONAL_CONTROL, '')
64
+ .replace(/\s+/gu, ' ')
65
+ .trim()
66
+ }
67
+
68
+ /**
69
+ * Truncate a string to a UTF-8 byte budget without splitting a code point.
70
+ *
71
+ * Windows clips a toast line by width, which can leave half a character; cutting
72
+ * on a code-point boundary with an ellipsis reads as deliberate.
73
+ *
74
+ * @param input - text to truncate.
75
+ * @param maxBytes - positive byte budget.
76
+ * @returns the possibly shortened text.
77
+ */
78
+ export function truncateUtf8(input, maxBytes) {
79
+ if (Buffer.byteLength(input, 'utf8') <= maxBytes) return input
80
+ const ellipsis = '…'
81
+ const budget = Math.max(1, maxBytes - Buffer.byteLength(ellipsis, 'utf8'))
82
+ let used = 0
83
+ let output = ''
84
+ for (const character of input) {
85
+ const bytes = Buffer.byteLength(character, 'utf8')
86
+ if (used + bytes > budget) break
87
+ output += character
88
+ used += bytes
89
+ }
90
+ return `${output.trimEnd()}${ellipsis}`
91
+ }
92
+
93
+ /**
94
+ * Clean and bound one line of notification text.
95
+ *
96
+ * @param input - raw text.
97
+ * @param maxBytes - byte budget.
98
+ * @returns the finished line.
99
+ */
100
+ export function line(input, maxBytes) {
101
+ return truncateUtf8(cleanText(input), maxBytes)
102
+ }
103
+
104
+ /**
105
+ * Last path segment of a directory, used when a conversation has no title yet.
106
+ *
107
+ * @param path - absolute directory path.
108
+ * @returns the trailing segment, or an empty string.
109
+ */
110
+ export function basenameOf(path) {
111
+ const text = typeof path === 'string' ? path.replace(/[\\/]+$/u, '') : ''
112
+ if (text === '') return ''
113
+ const parts = text.split(/[\\/]/u)
114
+ return parts[parts.length - 1] ?? ''
115
+ }
116
+
117
+ /**
118
+ * The conversation line of a notification.
119
+ *
120
+ * A session's own title is the best label, but a brand-new conversation has none
121
+ * until its first prompt is folded into one, and a notification about it must
122
+ * still say something useful — hence the directory fallback before the raw id.
123
+ *
124
+ * @param info - `{ title, cwd, id }`.
125
+ * @returns the conversation line.
126
+ */
127
+ export function sessionLabel(info) {
128
+ const titled = cleanText(info?.title)
129
+ if (titled !== '') return truncateUtf8(titled, SESSION_BYTES)
130
+ const directory = basenameOf(info?.cwd)
131
+ if (directory !== '') return truncateUtf8(directory, SESSION_BYTES)
132
+ const id = cleanText(info?.id)
133
+ return id === '' ? '会话' : truncateUtf8(id, SESSION_BYTES)
134
+ }
135
+
136
+ /**
137
+ * Wording for one cancellation cause carried by an aborted turn.
138
+ *
139
+ * @param cause - `TurnEndCancelCause`.
140
+ * @returns a short Chinese phrase, or an empty string when nothing is known.
141
+ */
142
+ export function describeCancelCause(cause) {
143
+ if (cause === null || typeof cause !== 'object') return ''
144
+ switch (cause.kind) {
145
+ case 'user':
146
+ return '你取消了这次任务'
147
+ case 'parent':
148
+ return '父会话取消了这次任务'
149
+ case 'hook':
150
+ return `扩展取消了这次任务:${cleanText(cause.reason)}`
151
+ case 'disposed':
152
+ return '会话已关闭'
153
+ case 'legacy':
154
+ return '任务被取消'
155
+ default:
156
+ return ''
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Build the notification descriptor for a pending approval request.
162
+ *
163
+ * This is raised BEFORE the composed answerers run, so the user learns that a
164
+ * decision is waiting even when the Harness window is behind another one.
165
+ *
166
+ * @param input - `{ info, toolName, reason, displayReason }`.
167
+ * @returns the descriptor.
168
+ */
169
+ export function describeApproval(input) {
170
+ const toolName = cleanText(input?.toolName)
171
+ const display = input?.displayReason
172
+ const localized =
173
+ display !== null && typeof display === 'object'
174
+ ? cleanText(display.zh ?? display['zh-CN'] ?? display.en)
175
+ : ''
176
+ const reason = cleanText(input?.reason)
177
+ const detail = [toolName === '' ? '' : `工具 ${toolName}`, localized || reason].filter((part) => part !== '').join(' · ')
178
+ return {
179
+ kind: 'approval',
180
+ title: '需要你确认权限',
181
+ session: sessionLabel(input?.info),
182
+ detail: line(detail, DETAIL_BYTES),
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Build the notification descriptor for a structured question.
188
+ *
189
+ * @param input - `{ info, questions }`.
190
+ * @returns the descriptor.
191
+ */
192
+ export function describeQuestion(input) {
193
+ const questions = Array.isArray(input?.questions) ? input.questions : []
194
+ const first = questions[0]
195
+ const asked = cleanText(first?.question)
196
+ const header = cleanText(first?.header)
197
+ const primary = asked === '' ? header : asked
198
+ const detail = questions.length > 1 ? `${primary}(共 ${questions.length} 个问题)` : primary
199
+ return {
200
+ kind: 'question',
201
+ title: '需要你回答',
202
+ session: sessionLabel(input?.info),
203
+ detail: line(detail, DETAIL_BYTES),
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Wording of one `turn/end` reason.
209
+ *
210
+ * `null` means "do not announce this": a fork is a copy, not a finished piece of
211
+ * work, and announcing it would claim the user's attention for nothing.
212
+ */
213
+ const TURN_END_WORDING = {
214
+ completed: { kind: 'done', title: '任务完成' },
215
+ 'max-tokens': { kind: 'done', title: '任务结束(达到长度上限)' },
216
+ aborted: { kind: 'aborted', title: '任务已中断' },
217
+ interrupted: { kind: 'aborted', title: '任务已中断' },
218
+ blocked: { kind: 'aborted', title: '任务被阻塞' },
219
+ error: { kind: 'error', title: '任务出错' },
220
+ forked: null,
221
+ }
222
+
223
+ /**
224
+ * Build the notification descriptor for a finished turn.
225
+ *
226
+ * @param input - `{ info, reason, durationMs }`.
227
+ * @returns the descriptor, or `null` when this reason is not announced.
228
+ */
229
+ export function describeTurnEnd(input) {
230
+ const reason = input?.reason
231
+ const kindName = reason !== null && typeof reason === 'object' ? reason.kind : undefined
232
+ const wording = Object.prototype.hasOwnProperty.call(TURN_END_WORDING, kindName) ? TURN_END_WORDING[kindName] : null
233
+ if (wording === null || wording === undefined) return null
234
+
235
+ let detail = ''
236
+ if (kindName === 'aborted') detail = describeCancelCause(reason.reason)
237
+ else if (kindName === 'error') detail = cleanText(reason.error?.message)
238
+ else if (kindName === 'max-tokens') detail = '模型回复达到输出上限,任务在此停下'
239
+ else if (kindName === 'blocked') detail = '任务需要新的输入才能继续'
240
+
241
+ const seconds = Number.isFinite(input?.durationMs) && input.durationMs >= 0 ? Math.round(input.durationMs / 1000) : null
242
+ if (seconds !== null && seconds >= 1 && kindName === 'completed') {
243
+ detail = detail === '' ? `用时 ${seconds} 秒` : `${detail} · 用时 ${seconds} 秒`
244
+ }
245
+
246
+ return {
247
+ kind: wording.kind,
248
+ title: wording.title,
249
+ session: sessionLabel(input?.info),
250
+ detail: line(detail, DETAIL_BYTES),
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Build the notification descriptor for a turn that failed outside a durable
256
+ * turn position (`agent/error`).
257
+ *
258
+ * @param input - `{ info, error }`.
259
+ * @returns the descriptor.
260
+ */
261
+ export function describeAgentError(input) {
262
+ const error = input?.error
263
+ const message =
264
+ error !== null && typeof error === 'object' && typeof error.message === 'string'
265
+ ? error.message
266
+ : typeof error === 'string'
267
+ ? error
268
+ : ''
269
+ return {
270
+ kind: 'error',
271
+ title: '任务出错',
272
+ session: sessionLabel(input?.info),
273
+ detail: line(message, DETAIL_BYTES),
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Whether one session should be announced under a scope.
279
+ *
280
+ * A subagent or a teammate is work the user did not start by hand, and a busy
281
+ * Lead can spawn dozens of them; announcing each turn would bury the
282
+ * conversation the user is actually following. `roots` therefore keeps the
283
+ * notifications to sessions the user opened himself.
284
+ *
285
+ * @param header - `SessionHeader`-shaped object.
286
+ * @param scope - `'roots'` or `'all'`.
287
+ * @returns true when this session is in scope.
288
+ */
289
+ export function isInScope(header, scope) {
290
+ if (scope === 'all') return true
291
+ if (header === null || typeof header !== 'object') return true
292
+ if (header.origin === 'subagent') return false
293
+ if (header.parentSession !== undefined && header.parentSession !== null) return false
294
+ return true
295
+ }
296
+
297
+ /**
298
+ * The activation argument a toast carries.
299
+ *
300
+ * The host owns the meaning of this URL; it is included in the descriptor so the
301
+ * pure formatting layer stays independent of the transport.
302
+ *
303
+ * @param scheme - registered protocol scheme.
304
+ * @param sessionId - conversation to open.
305
+ * @param kind - notification class.
306
+ * @returns the protocol URL.
307
+ */
308
+ export function openUrl(scheme, sessionId, kind) {
309
+ const query = new URLSearchParams()
310
+ query.set('s', String(sessionId ?? ''))
311
+ query.set('k', String(kind ?? ''))
312
+ return `${scheme}:open?${query.toString()}`
313
+ }
@@ -0,0 +1,16 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="dsh-notice">
2
+ <!-- A notification bell over the Harness's signal colour: the plugin is the
3
+ moment a conversation turns to face the user. -->
4
+ <defs>
5
+ <linearGradient id="bell" x1="0" y1="0" x2="0" y2="1">
6
+ <stop offset="0" stop-color="#4d8dff"/>
7
+ <stop offset="1" stop-color="#2470e8"/>
8
+ </linearGradient>
9
+ </defs>
10
+ <rect width="64" height="64" rx="14" fill="#0f1420"/>
11
+ <path d="M32 12c-7.2 0-13 5.8-13 13v9.4l-3.6 6.1a2 2 0 0 0 1.7 3h29.8a2 2 0 0 0 1.7-3L45 34.4V25c0-7.2-5.8-13-13-13Z"
12
+ fill="url(#bell)"/>
13
+ <path d="M26.6 46.4a5.6 5.6 0 0 0 10.8 0Z" fill="#8fb6ff"/>
14
+ <circle cx="45.5" cy="19.5" r="6.5" fill="#ff5b4a"/>
15
+ <circle cx="45.5" cy="19.5" r="2.6" fill="#0f1420" opacity=".55"/>
16
+ </svg>