weir-harness 1.2.2 → 1.2.3
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/package.json +1 -1
- package/src/intent-gate/matcher.js +27 -0
- package/src/lsp/admin.js +24 -13
- package/src/lsp/index.js +5 -2
- package/src/lsp/registry.js +83 -17
- package/src/notify/commands.js +15 -2
- package/src/notify/index.js +48 -10
- package/src/notify/web-channel.js +26 -3
- package/src/process/index.js +11 -0
- package/src/settings/sections.js +27 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "weir-harness",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Weir \u2014 orchestrator-centric DSH agent preset: intent gate, category delegation, todo driver, pull-based background jobs, auto-compaction, hashline edit.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -20,6 +20,7 @@ export function stripQuotedRegions(text) {
|
|
|
20
20
|
* @property {string} id
|
|
21
21
|
* @property {RegExp[]} patterns - compiled matchers
|
|
22
22
|
* @property {boolean} oncePerSession
|
|
23
|
+
* @property {string} [summary] - one-line catalog description for the semantic classifier
|
|
23
24
|
* @property {{ kind: 'skill-pointer', skill: string }
|
|
24
25
|
* | { kind: 'message', text: string }
|
|
25
26
|
* | { kind: 'effort', reasoningEffort: string }} injection
|
|
@@ -76,6 +77,7 @@ export function compileIntentTable(raw) {
|
|
|
76
77
|
id: entry.id,
|
|
77
78
|
patterns,
|
|
78
79
|
oncePerSession: entry.oncePerSession !== false,
|
|
80
|
+
...(typeof entry.summary === 'string' && entry.summary.length > 0 ? { summary: entry.summary } : {}),
|
|
79
81
|
injection,
|
|
80
82
|
}
|
|
81
83
|
})
|
|
@@ -91,6 +93,16 @@ export function matchIntents(stripped, intents) {
|
|
|
91
93
|
return intents.filter((intent) => intent.patterns.some((pattern) => pattern.test(stripped)))
|
|
92
94
|
}
|
|
93
95
|
|
|
96
|
+
/** Verbatim directive injected on every writing intent hit. */
|
|
97
|
+
export const WRITING_DIRECTIVE = `<intent-gate id="writing">
|
|
98
|
+
Write the deliverable as the finished product, not as a record of producing it.
|
|
99
|
+
- Keep the agent's thinking, implementation rationale, design reasoning, debugging narrative, and next-step plans out of the deliverable.
|
|
100
|
+
- Do not write explanatory UI copy — "I will", "we can", "this page shows", "here you'll find", "used to illustrate", "implemented" — unless it is genuine product help text or empty-state copy.
|
|
101
|
+
- Put implementation approach, verification method, limitations, and trade-offs only in the chat reply, code comments, the PR/MR description, docs, or plan files.
|
|
102
|
+
- Exception: include design notes, methodology, implementation walkthrough, or a work log in the deliverable only when the user explicitly asks for them.
|
|
103
|
+
- Unless there is a real need, a change leaves no trace: no comments, notes, markers, or any "why this changed" evidence. Code should look like it was always that way. The best change looks like no change was made — present the result, not the change itself.
|
|
104
|
+
</intent-gate>`
|
|
105
|
+
|
|
94
106
|
/** The default intent table (English templates; runtime content follows the session language). */
|
|
95
107
|
export const DEFAULT_INTENTS = [
|
|
96
108
|
{
|
|
@@ -113,6 +125,21 @@ export const DEFAULT_INTENTS = [
|
|
|
113
125
|
matchers: ['\\bdebug\\b', '\\bdebugging\\b', '调试', '排查'],
|
|
114
126
|
injection: { kind: 'skill-pointer', skill: 'debugging' },
|
|
115
127
|
},
|
|
128
|
+
{
|
|
129
|
+
id: 'writing',
|
|
130
|
+
matchers: [
|
|
131
|
+
'\\bwrite (?:a |an |the |some )?(?:[a-z][\\w-]* )?(?:doc|docs|document|documents|documentation|readme|guide|manual|report|spec)\\b',
|
|
132
|
+
'\\b(?:draft|compose|author|produce|prepare|rewrite|revise|update|polish) (?:a |an |the |some )?(?:[a-z][\\w-]* )?(?:doc|docs|document|documentation|readme|guide|manual|report)\\b',
|
|
133
|
+
'\\bwrite[- ]?up\\b',
|
|
134
|
+
'\\bdocument (?:the|this|these|a|an)\\b',
|
|
135
|
+
'(?:写|撰写|编写|起草|整理成|写成|润色|补全|更新)[^\\n]{0,8}(?:文档|说明|报告|指南|手册|教程|文案)',
|
|
136
|
+
'(?:写|撰写|编写|起草|润色|补全|更新)[^\\n]{0,8}readme',
|
|
137
|
+
'文档(?:撰写|编写|写作|润色|补全|更新)',
|
|
138
|
+
],
|
|
139
|
+
oncePerSession: false,
|
|
140
|
+
summary: 'author a documentation-style deliverable (docs, README, guide, report) as the finished result',
|
|
141
|
+
injection: { kind: 'message', text: WRITING_DIRECTIVE },
|
|
142
|
+
},
|
|
116
143
|
{
|
|
117
144
|
id: 'think',
|
|
118
145
|
matchers: ['\\bthink hard\\b', '\\bultrathink\\b', '深思熟虑'],
|
package/src/lsp/admin.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// the community catalog). Plain ESM, ctx-only; `connection`/`subprocess`
|
|
4
4
|
// resolve through ctx.get so the module mounts harmlessly in compositions
|
|
5
5
|
// without them (headless).
|
|
6
|
-
import { buildRegistry,
|
|
6
|
+
import { buildRegistry, displayInstallCommand, installSpecFor, languageIdsForFamily, serverForPlatform } from './registry.js'
|
|
7
7
|
import { npmGlobalPrefix, resolveExecutable as extendedResolveExecutable, runBounded, spawnArgv } from '../process/index.js'
|
|
8
8
|
import { jsonResponse, readJsonBody } from '../shared/http-json.js'
|
|
9
9
|
|
|
@@ -39,20 +39,27 @@ export async function probeVersion(subprocess, executable, args = ['--version'],
|
|
|
39
39
|
return first ?? null
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* Per-family status rows over the community catalog. `options.platform`
|
|
44
|
+
* (default: the host) resolves each family's platform row first, so every
|
|
45
|
+
* displayed value — command, version probe, install command, hint — belongs to
|
|
46
|
+
* the platform being reported (D2/D5).
|
|
47
|
+
*/
|
|
43
48
|
export async function lspStatusFor(registry, subprocess, options = {}) {
|
|
44
49
|
const probeTimeoutMs = options.probeTimeoutMs ?? VERSION_PROBE_TIMEOUT_MS
|
|
45
50
|
const dirs = options.dirs ?? undefined
|
|
51
|
+
const platform = options.platform ?? process.platform
|
|
46
52
|
const servers = []
|
|
47
53
|
for (const [family, entry] of Object.entries(registry)) {
|
|
54
|
+
const resolved = serverForPlatform(entry, platform)
|
|
48
55
|
let installed = false
|
|
49
56
|
let version = null
|
|
50
57
|
try {
|
|
51
|
-
const executable = await extendedResolveExecutable(subprocess,
|
|
58
|
+
const executable = await extendedResolveExecutable(subprocess, resolved.command, dirs)
|
|
52
59
|
installed = Boolean(executable)
|
|
53
60
|
if (executable) {
|
|
54
61
|
try {
|
|
55
|
-
version = await probeVersion(subprocess, executable,
|
|
62
|
+
version = await probeVersion(subprocess, executable, resolved.versionArgs ?? ['--version'], probeTimeoutMs)
|
|
56
63
|
} catch {
|
|
57
64
|
version = null
|
|
58
65
|
}
|
|
@@ -61,7 +68,7 @@ export async function lspStatusFor(registry, subprocess, options = {}) {
|
|
|
61
68
|
// unresolvable command = not installed
|
|
62
69
|
}
|
|
63
70
|
let installerAvailable = false
|
|
64
|
-
const installerSpec = installSpecFor(
|
|
71
|
+
const installerSpec = installSpecFor(resolved, platform)
|
|
65
72
|
if (installerSpec) {
|
|
66
73
|
try {
|
|
67
74
|
installerAvailable = Boolean(await extendedResolveExecutable(subprocess, installerSpec.command, dirs))
|
|
@@ -72,11 +79,11 @@ export async function lspStatusFor(registry, subprocess, options = {}) {
|
|
|
72
79
|
servers.push({
|
|
73
80
|
family,
|
|
74
81
|
languageIds: languageIdsForFamily(family),
|
|
75
|
-
command:
|
|
82
|
+
command: resolved.command,
|
|
76
83
|
installed,
|
|
77
84
|
version,
|
|
78
|
-
installCommand: displayInstallCommand(
|
|
79
|
-
installHint:
|
|
85
|
+
installCommand: displayInstallCommand(resolved, platform),
|
|
86
|
+
installHint: resolved.installHint ?? '',
|
|
80
87
|
installerAvailable,
|
|
81
88
|
})
|
|
82
89
|
}
|
|
@@ -87,13 +94,15 @@ export async function lspStatusFor(registry, subprocess, options = {}) {
|
|
|
87
94
|
* Run one family's install command: argv-only (no shell), output captured,
|
|
88
95
|
* bounded by a timeout. Resolves `{ output, exitCode, timedOut }`; throws a
|
|
89
96
|
* structured Error for unknown families, missing installers, or a missing
|
|
90
|
-
* installer binary. `dirs` overrides the extra bin-directory scan (tests)
|
|
97
|
+
* installer binary. `dirs` overrides the extra bin-directory scan (tests);
|
|
98
|
+
* `platform` selects the family's platform row and its install spec (D2).
|
|
91
99
|
*/
|
|
92
|
-
export async function runInstall(registry, subprocess, family, timeoutMs = DEFAULT_INSTALL_TIMEOUT_MS, dirs = undefined) {
|
|
100
|
+
export async function runInstall(registry, subprocess, family, timeoutMs = DEFAULT_INSTALL_TIMEOUT_MS, dirs = undefined, platform = process.platform) {
|
|
93
101
|
const entry = registry[family]
|
|
94
102
|
if (!entry) throw new Error(`lsp: unknown language family '${family}'`)
|
|
95
|
-
const
|
|
96
|
-
|
|
103
|
+
const resolved = serverForPlatform(entry, platform)
|
|
104
|
+
const spec = installSpecFor(resolved, platform)
|
|
105
|
+
if (!spec) throw new Error(`lsp: no installer for '${family}' on this platform — ${resolved.installHint ?? 'see the install hint'}`)
|
|
97
106
|
const executable = await extendedResolveExecutable(subprocess, spec.command, dirs).catch(() => undefined)
|
|
98
107
|
if (!executable) throw new Error(`lsp: installer '${spec.command}' not found on PATH — install it first`)
|
|
99
108
|
// npm installs pin a user-writable prefix: the resolved npm may belong to
|
|
@@ -127,7 +136,9 @@ export function registerLspAdminEndpoints(ctx, options = {}) {
|
|
|
127
136
|
ctx.logger?.warn?.('weir-lsp-admin: connection/subprocess unavailable — LSP management endpoints not registered')
|
|
128
137
|
return () => {}
|
|
129
138
|
}
|
|
130
|
-
|
|
139
|
+
// The catalog is platform-scoped: an absent provider builds the registry for
|
|
140
|
+
// the running host (rows resolved), never the raw unresolved DEFAULT_SERVERS.
|
|
141
|
+
const registryProvider = typeof options.registry === 'function' ? options.registry : () => buildRegistry()
|
|
131
142
|
const installTimeoutMs = options.installTimeoutMs ?? DEFAULT_INSTALL_TIMEOUT_MS
|
|
132
143
|
const dirs = options.dirs ?? undefined
|
|
133
144
|
const disposers = []
|
package/src/lsp/index.js
CHANGED
|
@@ -81,7 +81,10 @@ function apply(ctx, config = {}) {
|
|
|
81
81
|
manager = createLspManager({
|
|
82
82
|
subprocess: ctx.subprocess,
|
|
83
83
|
fs: ctx.fs,
|
|
84
|
-
|
|
84
|
+
// `config.platform` is the test seam: the registry resolves the running
|
|
85
|
+
// host's rows unless a caller pins the platform (D2 — consumers below
|
|
86
|
+
// only ever see flat, already-resolved entries).
|
|
87
|
+
registry: buildRegistry(option('servers', config.servers), config.platform ?? process.platform),
|
|
85
88
|
// config.extraBinDirs overrides the extended-resolution scan dirs
|
|
86
89
|
// (hermetic tests; power users with exotic install locations)
|
|
87
90
|
resolveExecutable: (command) => extendedResolveExecutable(ctx.subprocess, command, config.extraBinDirs),
|
|
@@ -244,7 +247,7 @@ function apply(ctx, config = {}) {
|
|
|
244
247
|
diagnosticsWaitMs: option('diagnosticsWaitMs', config.diagnosticsWaitMs),
|
|
245
248
|
}
|
|
246
249
|
manager.setOptions(Object.fromEntries(Object.entries(next).filter(([, value]) => value !== undefined)))
|
|
247
|
-
manager.setRegistry(buildRegistry(option('servers', config.servers)))
|
|
250
|
+
manager.setRegistry(buildRegistry(option('servers', config.servers), config.platform ?? process.platform))
|
|
248
251
|
}
|
|
249
252
|
})
|
|
250
253
|
applyGate()
|
package/src/lsp/registry.js
CHANGED
|
@@ -64,6 +64,14 @@ export const LANGUAGE_FAMILY = {
|
|
|
64
64
|
cpp: 'cpp',
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Built-in catalog. An entry may carry `platforms: { darwin?, linux?, win32?,
|
|
69
|
+
* default? }`; every row has the entry shape (all fields optional) and
|
|
70
|
+
* overrides only the fields it declares for that platform (D1). A family whose
|
|
71
|
+
* data is identical on every platform declares it once as the shared base
|
|
72
|
+
* (npm/pipx/go/rustup); the rest keep a platform-NEUTRAL base plus one row per
|
|
73
|
+
* platform, so no platform ever sees another platform's installer (D5).
|
|
74
|
+
*/
|
|
67
75
|
export const DEFAULT_SERVERS = {
|
|
68
76
|
typescript: {
|
|
69
77
|
command: 'typescript-language-server',
|
|
@@ -155,19 +163,41 @@ export const DEFAULT_SERVERS = {
|
|
|
155
163
|
command: 'lua-language-server',
|
|
156
164
|
args: [],
|
|
157
165
|
manifests: ['*.lua'],
|
|
158
|
-
installHint: '
|
|
159
|
-
|
|
160
|
-
darwin: {
|
|
166
|
+
installHint: 'install LuaLS from https://luals.github.io',
|
|
167
|
+
platforms: {
|
|
168
|
+
darwin: {
|
|
169
|
+
installHint: 'brew install lua-language-server',
|
|
170
|
+
install: { command: 'brew', args: ['install', 'lua-language-server'] },
|
|
171
|
+
},
|
|
172
|
+
linux: {
|
|
173
|
+
// no package-managed LuaLS on Linux: hint only, pointing at the release
|
|
174
|
+
// page (a one-click install would be a guess, not a recipe)
|
|
175
|
+
installHint: 'no package-managed LuaLS on Linux — download it from https://github.com/LuaLS/lua-language-server/releases',
|
|
176
|
+
},
|
|
177
|
+
win32: {
|
|
178
|
+
installHint: 'winget install --id LuaLS.lua-language-server -e --accept-source-agreements --accept-package-agreements',
|
|
179
|
+
install: { command: 'winget', args: ['install', '--id', 'LuaLS.lua-language-server', '-e', '--accept-source-agreements', '--accept-package-agreements'] },
|
|
180
|
+
},
|
|
161
181
|
},
|
|
162
182
|
},
|
|
163
183
|
cpp: {
|
|
164
184
|
command: 'clangd',
|
|
165
185
|
args: [],
|
|
166
186
|
manifests: ['*.c', '*.cpp', 'compile_commands.json'],
|
|
167
|
-
installHint: '
|
|
168
|
-
|
|
169
|
-
darwin: {
|
|
170
|
-
|
|
187
|
+
installHint: 'install clangd from https://clangd.llvm.org/installation',
|
|
188
|
+
platforms: {
|
|
189
|
+
darwin: {
|
|
190
|
+
installHint: 'brew install llvm',
|
|
191
|
+
install: { command: 'brew', args: ['install', 'llvm'] },
|
|
192
|
+
},
|
|
193
|
+
linux: {
|
|
194
|
+
installHint: 'sudo apt-get install clangd',
|
|
195
|
+
install: { command: 'sudo', args: ['apt-get', 'install', '-y', 'clangd'] },
|
|
196
|
+
},
|
|
197
|
+
win32: {
|
|
198
|
+
installHint: 'winget install --id LLVM.LLVM -e --accept-source-agreements --accept-package-agreements',
|
|
199
|
+
install: { command: 'winget', args: ['install', '--id', 'LLVM.LLVM', '-e', '--accept-source-agreements', '--accept-package-agreements'] },
|
|
200
|
+
},
|
|
171
201
|
},
|
|
172
202
|
},
|
|
173
203
|
}
|
|
@@ -192,26 +222,62 @@ export function languageIdsForFamily(family) {
|
|
|
192
222
|
return Object.entries(LANGUAGE_FAMILY).filter(([, value]) => value === family).map(([id]) => id)
|
|
193
223
|
}
|
|
194
224
|
|
|
195
|
-
/**
|
|
225
|
+
/**
|
|
226
|
+
* Resolve one entry for a platform: `{...entry, ...platforms[platform]}` with
|
|
227
|
+
* the `platforms` key removed (D1/D2). An entry without a `platforms` map is
|
|
228
|
+
* returned unchanged — the platform is never guessed. A platform with no row
|
|
229
|
+
* (and no `default` row) keeps the entry's base fields, which are
|
|
230
|
+
* platform-neutral by contract — never another platform's recipe (D5).
|
|
231
|
+
*/
|
|
232
|
+
export function serverForPlatform(entry, platform = process.platform) {
|
|
233
|
+
const platforms = entry?.platforms
|
|
234
|
+
if (!platforms || typeof platforms !== 'object' || Array.isArray(platforms)) return entry
|
|
235
|
+
const row = platforms[platform] ?? platforms.default
|
|
236
|
+
const resolved = { ...entry, ...(row && typeof row === 'object' ? row : {}) }
|
|
237
|
+
delete resolved.platforms
|
|
238
|
+
return resolved
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Platform-resolved install spec for a registry entry, or undefined. The entry
|
|
243
|
+
* is resolved first when it declares `platforms`. A single spec is identified
|
|
244
|
+
* by `install.command` being a string (a platform map never has a `command`
|
|
245
|
+
* key — D7); anything else is the legacy platform map, kept so existing user
|
|
246
|
+
* configs keep resolving.
|
|
247
|
+
*/
|
|
196
248
|
export function installSpecFor(entry, platform = process.platform) {
|
|
197
|
-
const
|
|
249
|
+
const resolved = entry?.platforms ? serverForPlatform(entry, platform) : entry
|
|
250
|
+
const install = resolved?.install
|
|
198
251
|
if (!install) return undefined
|
|
199
|
-
if (
|
|
252
|
+
if (typeof install.command === 'string') return install // single-spec form
|
|
200
253
|
return install[platform] ?? install.default
|
|
201
254
|
}
|
|
202
255
|
|
|
203
|
-
/**
|
|
256
|
+
/**
|
|
257
|
+
* Display form of the install command for a registry entry. Where the platform
|
|
258
|
+
* declares no install spec the fallback is the RESOLVED entry's `installHint`
|
|
259
|
+
* — never another platform's command.
|
|
260
|
+
*/
|
|
204
261
|
export function displayInstallCommand(entry, platform = process.platform) {
|
|
205
|
-
const
|
|
206
|
-
|
|
262
|
+
const resolved = entry?.platforms ? serverForPlatform(entry, platform) : entry
|
|
263
|
+
const spec = installSpecFor(resolved, platform)
|
|
264
|
+
if (!spec) return resolved?.installHint ?? ''
|
|
207
265
|
return [spec.command, ...(spec.args ?? [])].join(' ')
|
|
208
266
|
}
|
|
209
267
|
|
|
210
|
-
/**
|
|
211
|
-
|
|
212
|
-
|
|
268
|
+
/**
|
|
269
|
+
* Merge user `lspServers` config over the platform-resolved catalog. Built-ins
|
|
270
|
+
* resolve first; each user override is then merged onto the resolved built-in
|
|
271
|
+
* and resolved again, so a user row overrides only the fields it declares and
|
|
272
|
+
* a user platform row wins over the built-in one (D2/D6).
|
|
273
|
+
*/
|
|
274
|
+
export function buildRegistry(configServers, platform = process.platform) {
|
|
275
|
+
const registry = {}
|
|
276
|
+
for (const [language, entry] of Object.entries(DEFAULT_SERVERS)) {
|
|
277
|
+
registry[language] = serverForPlatform(entry, platform)
|
|
278
|
+
}
|
|
213
279
|
for (const [language, entry] of Object.entries(configServers ?? {})) {
|
|
214
|
-
registry[language] = { ...registry[language], ...entry }
|
|
280
|
+
registry[language] = serverForPlatform({ ...registry[language], ...entry }, platform)
|
|
215
281
|
}
|
|
216
282
|
return registry
|
|
217
283
|
}
|
package/src/notify/commands.js
CHANGED
|
@@ -54,7 +54,10 @@ function linux(note, sound) {
|
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
/**
|
|
57
|
+
/**
|
|
58
|
+
* PowerShell source of the Windows toast; reads title/body from the environment
|
|
59
|
+
* and reports an OS-level block through stderr plus a non-zero exit.
|
|
60
|
+
*/
|
|
58
61
|
const WINDOWS_SCRIPT = [
|
|
59
62
|
"$ErrorActionPreference = 'Stop'",
|
|
60
63
|
'[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null',
|
|
@@ -66,7 +69,17 @@ const WINDOWS_SCRIPT = [
|
|
|
66
69
|
'$xml.LoadXml("<toast><visual><binding template=\'ToastGeneric\'><text>$title</text><text>$body</text></binding></visual>$audio</toast>")',
|
|
67
70
|
'$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)',
|
|
68
71
|
"$appId = '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe'",
|
|
69
|
-
|
|
72
|
+
// `Show()` has no synchronous failure return, and the asynchronous
|
|
73
|
+
// `ToastNotification.Failed` event cannot be awaited by a process that exits
|
|
74
|
+
// right after firing, so `ToastNotifier.Setting` is the synchronous proxy: a
|
|
75
|
+
// non-`Enabled` value (disabled for the app, the user, by group policy or by
|
|
76
|
+
// manifest) becomes a non-zero exit whose stderr reaches the warn-once path
|
|
77
|
+
// instead of vanishing into silence. Explicit variables, not a chained
|
|
78
|
+
// expression: PowerShell 5.1 chaining behaviour must not be relied on.
|
|
79
|
+
'$notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($appId)',
|
|
80
|
+
'$setting = $notifier.Setting',
|
|
81
|
+
'if ("$setting" -ne \'Enabled\') { [Console]::Error.WriteLine("Windows toast notifications are unavailable: $setting"); exit 3 }',
|
|
82
|
+
'$notifier.Show($toast)',
|
|
70
83
|
].join('\n')
|
|
71
84
|
|
|
72
85
|
/**
|
package/src/notify/index.js
CHANGED
|
@@ -30,6 +30,32 @@ const STALE_EVENT_MS = 60_000
|
|
|
30
30
|
/** Longest parent chain walked to find the session the user actually sees. */
|
|
31
31
|
const MAX_ANCESTORS = 8
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Platforms whose page can both DISPLAY a notification and REPORT whether it
|
|
35
|
+
* appeared, so handing the note to the page first is sound there: macOS and
|
|
36
|
+
* Linux. Windows is deliberately absent — the page's web `Notification` can
|
|
37
|
+
* neither display nor report failure there. Electron only shows a toast for an
|
|
38
|
+
* app that owns a Start Menu shortcut with an AppUserModelID and a
|
|
39
|
+
* ToastActivatorCLSID (the DSH desktop bundle declares no app identity), so
|
|
40
|
+
* Chromium drops it with nothing but a debug log; and in a secure context with
|
|
41
|
+
* permission granted, Blink never fails the constructor and never fires
|
|
42
|
+
* `error`. The page could therefore only answer `shown`/`pending`, both
|
|
43
|
+
* terminal, which would suppress the fallback forever. So Windows goes
|
|
44
|
+
* straight to the platform command, whose AUMID points at an OS-registered
|
|
45
|
+
* shortcut. The cost: Windows attributes the toast to "Windows PowerShell".
|
|
46
|
+
*/
|
|
47
|
+
export const PAGE_DELIVERY_PLATFORMS = Object.freeze(['darwin', 'linux'])
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Whether the page leg can carry a delivery on this platform.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} platform - a `process.platform` value
|
|
53
|
+
* @returns {boolean}
|
|
54
|
+
*/
|
|
55
|
+
export function usesPageDelivery(platform) {
|
|
56
|
+
return PAGE_DELIVERY_PLATFORMS.includes(platform)
|
|
57
|
+
}
|
|
58
|
+
|
|
33
59
|
/**
|
|
34
60
|
* Resolve the effective options NOW (the settings service is live: a
|
|
35
61
|
* volatile commit takes effect on the next event, no remount). Unusable
|
|
@@ -238,26 +264,38 @@ export function wire(ctx, config, { notifier, channel, now = Date.now, setTimer,
|
|
|
238
264
|
}
|
|
239
265
|
}
|
|
240
266
|
|
|
241
|
-
/**
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
267
|
+
/**
|
|
268
|
+
* @param {any} ctx
|
|
269
|
+
* @param {object} [config]
|
|
270
|
+
* @param {{ platform?: string }} [options] - test seam; cordis calls (ctx, config) only
|
|
271
|
+
*/
|
|
272
|
+
function apply(ctx, config = {}, { platform = process.platform } = {}) {
|
|
273
|
+
const notifier = createNotifier({ platform, logger: ctx.logger })
|
|
274
|
+
if (!notifier.supported) ctx.logger?.warn?.(`notify: system notifications are not supported on platform "${platform}"`)
|
|
275
|
+
// The page delivers (as DeepSeek Harness) where it can also confirm; on the
|
|
276
|
+
// other platforms the system command is the only leg (PAGE_DELIVERY_PLATFORMS).
|
|
277
|
+
const channel = usesPageDelivery(platform)
|
|
278
|
+
? createWebChannel({ fallback: (note, options) => notifier.send(note, options), logger: ctx.logger })
|
|
279
|
+
: undefined
|
|
247
280
|
const offWire = wire(ctx, config, { notifier, channel })
|
|
248
281
|
// Page endpoints (pull/ack) and the macOS permission panel's endpoints. They
|
|
249
282
|
// register from this row, whose `./notify` subpath already exists, so no new
|
|
250
283
|
// package subpath is needed (S19); services come through ctx.inject (S20).
|
|
251
284
|
const offEndpoints = wireNotifyPermissions(ctx, {
|
|
285
|
+
platform,
|
|
252
286
|
channel,
|
|
253
|
-
// The permission panel's test goes through
|
|
254
|
-
// with the window in front (the user is looking at
|
|
255
|
-
|
|
287
|
+
// The permission panel's test goes through whichever path the platform
|
|
288
|
+
// uses, and shows even with the window in front (the user is looking at
|
|
289
|
+
// the settings page).
|
|
290
|
+
sendTest: () =>
|
|
291
|
+
channel
|
|
292
|
+
? channel.send({ ...TEST_NOTE, tag: tagFor('test', 'test'), renotify: RENOTIFY, sound: true, foreground: 'always' })
|
|
293
|
+
: notifier.send(TEST_NOTE, { sound: true }),
|
|
256
294
|
})
|
|
257
295
|
return () => {
|
|
258
296
|
offWire()
|
|
259
297
|
offEndpoints()
|
|
260
|
-
channel
|
|
298
|
+
channel?.close()
|
|
261
299
|
}
|
|
262
300
|
}
|
|
263
301
|
|
|
@@ -9,8 +9,13 @@
|
|
|
9
9
|
//
|
|
10
10
|
// Safety contract (spec "待发通知通道对失败与异常页面安全"):
|
|
11
11
|
// - a note is handed to exactly ONE puller;
|
|
12
|
-
// - a note is delivered at most once: a page that showed it,
|
|
13
|
-
//
|
|
12
|
+
// - a note is delivered at most once: a page that showed it, suppressed it on
|
|
13
|
+
// purpose (window in the foreground), or handed it off without confirmation
|
|
14
|
+
// (`pending`) is final — no fallback;
|
|
15
|
+
// - `pending` is final but is NOT confirmation: it means the page could not
|
|
16
|
+
// tell whether the notification appeared, so it is reported once per cause
|
|
17
|
+
// through the logger instead of falling back — a silent platform drop would
|
|
18
|
+
// otherwise be invisible to the user and to the log;
|
|
14
19
|
// - a note is never lost: no puller, a failed page, or a missing ack all fall
|
|
15
20
|
// back to the system command;
|
|
16
21
|
// - nothing here touches a session. Plain ESM, ctx-free; clock and timers are
|
|
@@ -25,11 +30,16 @@ export const ACK_TIMEOUT_MS = 4_000
|
|
|
25
30
|
|
|
26
31
|
/**
|
|
27
32
|
* What the page may report. `shown`/`pending`/`suppressed` are final (no
|
|
28
|
-
* fallback); everything else falls back.
|
|
33
|
+
* fallback); everything else falls back. `pending` is final without being
|
|
34
|
+
* confirmation — the page handed the note off and could not tell whether it
|
|
35
|
+
* appeared — so it is reported through the logger instead of falling back.
|
|
29
36
|
*/
|
|
30
37
|
export const FINAL_RESULTS = Object.freeze(['shown', 'pending', 'suppressed'])
|
|
31
38
|
export const FALLBACK_RESULTS = Object.freeze(['blocked', 'error', 'threw', 'unsupported'])
|
|
32
39
|
|
|
40
|
+
/** Reported once per channel when a page hands a note off but never confirms it. */
|
|
41
|
+
const UNCONFIRMED = 'the page sent a notification but could not confirm it appeared'
|
|
42
|
+
|
|
33
43
|
/**
|
|
34
44
|
* @typedef {{ title: string, body: string, urgent: boolean }} Note
|
|
35
45
|
* @typedef {Note & { tag: string, renotify: boolean, sound: boolean, foreground: 'skip' | 'always' }} WebNote
|
|
@@ -63,6 +73,15 @@ export function createWebChannel({ fallback, now = Date.now, setTimer, clearTime
|
|
|
63
73
|
let nextId = 1
|
|
64
74
|
let closed = false
|
|
65
75
|
|
|
76
|
+
/** Distinct warnings already emitted (one per cause, like the notifier). @type {Set<string>} */
|
|
77
|
+
const reported = new Set()
|
|
78
|
+
/** @param {string} cause */
|
|
79
|
+
const warnOnce = (cause) => {
|
|
80
|
+
if (reported.has(cause)) return
|
|
81
|
+
reported.add(cause)
|
|
82
|
+
logger?.warn?.(`notify: ${cause}`)
|
|
83
|
+
}
|
|
84
|
+
|
|
66
85
|
const hasPuller = () => waiters.length > 0 || now() - lastPullAt < PULLER_FRESH_MS
|
|
67
86
|
|
|
68
87
|
/** @param {WebNote} note */
|
|
@@ -165,6 +184,10 @@ export function createWebChannel({ fallback, now = Date.now, setTimer, clearTime
|
|
|
165
184
|
inFlight.delete(id)
|
|
166
185
|
stop(entry.timer)
|
|
167
186
|
if (!FINAL_RESULTS.includes(result)) useFallback(entry.note)
|
|
187
|
+
// `pending` is final (never fall back) but it is not confirmation: the
|
|
188
|
+
// page handed the note off and could not tell whether it appeared. Say so
|
|
189
|
+
// once per cause, or a silent platform drop stays invisible.
|
|
190
|
+
else if (result === 'pending') warnOnce(UNCONFIRMED)
|
|
168
191
|
return true
|
|
169
192
|
},
|
|
170
193
|
|
package/src/process/index.js
CHANGED
|
@@ -39,6 +39,8 @@ export function extraBinDirectories(env = process.env, platform = process.platfo
|
|
|
39
39
|
// describe, not which separator to spell, so a test on any host can point
|
|
40
40
|
// the seam at a real fixture directory.
|
|
41
41
|
const roaming = env.APPDATA ?? join(home, 'AppData', 'Roaming')
|
|
42
|
+
const localAppData = env.LOCALAPPDATA ?? join(home, 'AppData', 'Local')
|
|
43
|
+
const programFiles = env.ProgramFiles ?? 'C:\\Program Files'
|
|
42
44
|
return [
|
|
43
45
|
// npm's own global prefix: the .cmd/.ps1 shims live directly here.
|
|
44
46
|
join(roaming, 'npm'),
|
|
@@ -51,6 +53,15 @@ export function extraBinDirectories(env = process.env, platform = process.platfo
|
|
|
51
53
|
join(home, 'go', 'bin'),
|
|
52
54
|
join(home, 'scoop', 'shims'),
|
|
53
55
|
join(roaming, 'npm', 'node_modules', '.bin'),
|
|
56
|
+
// LLVM's NSIS installer skips the "add to PATH" step in silent mode, so
|
|
57
|
+
// these two are the ONLY way a winget-installed clangd resolves (D4).
|
|
58
|
+
join(programFiles, 'LLVM', 'bin'),
|
|
59
|
+
join(localAppData, 'Programs', 'LLVM', 'bin'),
|
|
60
|
+
// WinGet portable packages expose their command aliases here (LuaLS).
|
|
61
|
+
join(localAppData, 'Microsoft', 'WinGet', 'Links'),
|
|
62
|
+
// winget.exe itself lives here; a long-running GUI's PATH can predate the
|
|
63
|
+
// App Installer update, so the directory scan must cover it too.
|
|
64
|
+
join(localAppData, 'Microsoft', 'WindowsApps'),
|
|
54
65
|
]
|
|
55
66
|
}
|
|
56
67
|
const nvmBins = []
|
package/src/settings/sections.js
CHANGED
|
@@ -72,7 +72,7 @@ const FIELDS = [
|
|
|
72
72
|
{ key: 'worktreeAutoSetup', section: 'worktree', field: 'autoSetup', type: 'boolean', description: 'Install dependencies in a new lane automatically (configured setup, else lockfile-derived)' },
|
|
73
73
|
{ key: 'worktreeWatchTimeoutMinutes', section: 'worktree', field: 'watchTimeoutMinutes', type: 'number', description: 'Minutes a lane-state watch (worktree_watch) lives before expiring with one notice (default 360 = 6 hours, minimum 1); frozen per watch at subscribe time' },
|
|
74
74
|
{ key: 'worktreeAutoApprove', section: 'worktree', field: 'autoApprove', type: { union: ['manual', 'auto-keep', 'auto-clean'] }, description: 'Worktree auto-approve mode: manual shows every decision card; auto-keep auto-approves merges and abandons, removing the worktree but keeping the branch; auto-clean also deletes the branch. The initial mode for sessions without a /worktree approve override (default auto-clean); a session override wins over this global value' },
|
|
75
|
-
{ key: 'lspServers', section: 'lsp', field: 'servers', type: 'string', description: 'JSON map of custom language servers: family → { command, args?, manifests?, installHint?, install? }' },
|
|
75
|
+
{ key: 'lspServers', section: 'lsp', field: 'servers', type: 'string', description: 'JSON map of custom language servers: family → { command, args?, manifests?, installHint?, platforms?, install? }; a platforms map ({ darwin?, linux?, win32?, default? }) overrides only the fields its row declares for that platform' },
|
|
76
76
|
{ key: 'notifyEnabled', section: 'notify', field: 'enabled', type: 'boolean', description: 'System notifications master switch: tell the user when a session needs them or a turn finishes' },
|
|
77
77
|
{ key: 'notifyOnComplete', section: 'notify', field: 'onComplete', type: 'boolean', description: 'Notify when a turn finishes (only turns longer than notifyMinTurnSeconds)' },
|
|
78
78
|
{ key: 'notifyOnAttention', section: 'notify', field: 'onAttention', type: 'boolean', description: 'Notify when a session needs the user: approval, question, plan review, a failed or stopped turn' },
|
|
@@ -227,19 +227,42 @@ function chainsValidator(key) {
|
|
|
227
227
|
return validate
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
-
/**
|
|
230
|
+
/** True for a JSON object literal (arrays and null are not plain objects). */
|
|
231
|
+
function isPlainObject(value) {
|
|
232
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Parse + validate the lspServers JSON map (bad input fails activation loud).
|
|
236
|
+
* A family may declare `platforms: { darwin?, linux?, win32?, default? }`,
|
|
237
|
+
* each row shaped like an entry with every field optional; a malformed row
|
|
238
|
+
* names the family and field so activation failure is diagnosable (D6). */
|
|
231
239
|
function validateLspServers(raw) {
|
|
232
240
|
const parsed = JSON.parse(raw)
|
|
233
|
-
if (
|
|
241
|
+
if (!isPlainObject(parsed)) {
|
|
234
242
|
throw new Error('weir-settings: lspServers must be a JSON object map')
|
|
235
243
|
}
|
|
236
244
|
for (const [family, entry] of Object.entries(parsed)) {
|
|
237
|
-
if (!entry || typeof entry
|
|
245
|
+
if (!isPlainObject(entry) || typeof entry.command !== 'string' || entry.command.trim().length === 0) {
|
|
238
246
|
throw new Error(`weir-settings: lspServers.${family} needs a { command } entry`)
|
|
239
247
|
}
|
|
240
248
|
if (entry.args !== undefined && !Array.isArray(entry.args)) {
|
|
241
249
|
throw new Error(`weir-settings: lspServers.${family}.args must be an array`)
|
|
242
250
|
}
|
|
251
|
+
if (entry.platforms === undefined) continue
|
|
252
|
+
if (!isPlainObject(entry.platforms)) {
|
|
253
|
+
throw new Error(`weir-settings: lspServers.${family}.platforms must be a JSON object map`)
|
|
254
|
+
}
|
|
255
|
+
for (const [platform, row] of Object.entries(entry.platforms)) {
|
|
256
|
+
if (!isPlainObject(row)) {
|
|
257
|
+
throw new Error(`weir-settings: lspServers.${family}.platforms.${platform} must be a JSON object`)
|
|
258
|
+
}
|
|
259
|
+
if (row.command !== undefined && (typeof row.command !== 'string' || row.command.trim().length === 0)) {
|
|
260
|
+
throw new Error(`weir-settings: lspServers.${family}.platforms.${platform}.command must be a non-empty string`)
|
|
261
|
+
}
|
|
262
|
+
if (row.args !== undefined && !Array.isArray(row.args)) {
|
|
263
|
+
throw new Error(`weir-settings: lspServers.${family}.platforms.${platform}.args must be an array`)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
243
266
|
}
|
|
244
267
|
return parsed
|
|
245
268
|
}
|