dsh-workspace-plus 0.4.0 → 0.6.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.
- package/README.md +35 -446
- package/cordis.patch.yml +45 -8
- package/lib/client.js +1040 -231
- package/lib/notice/click.ps1 +67 -0
- package/lib/notice/format.js +313 -0
- package/lib/notice/icon.svg +16 -0
- package/lib/notice/index.js +951 -0
- package/lib/notice/locale/en.json +6 -0
- package/lib/notice/locale/zh.json +6 -0
- package/lib/notice/package.json +6 -0
- package/lib/notice/settings.js +336 -0
- package/lib/notice/toast-host.ps1 +616 -0
- package/lib/notice/toast.js +517 -0
- package/lib/notice/win32.js +206 -0
- package/package.json +12 -4
|
@@ -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>
|