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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "weir-harness",
3
- "version": "1.2.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, DEFAULT_SERVERS, displayInstallCommand, installSpecFor, languageIdsForFamily } from './registry.js'
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
- /** Per-family status rows over the community catalog. */
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, entry.command, dirs)
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, entry.versionArgs ?? ['--version'], probeTimeoutMs)
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(entry)
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: entry.command,
82
+ command: resolved.command,
76
83
  installed,
77
84
  version,
78
- installCommand: displayInstallCommand(entry),
79
- installHint: entry.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 spec = installSpecFor(entry)
96
- if (!spec) throw new Error(`lsp: no installer for '${family}' on this platform — ${entry.installHint ?? 'see the install hint'}`)
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
- const registryProvider = typeof options.registry === 'function' ? options.registry : () => DEFAULT_SERVERS
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
- registry: buildRegistry(option('servers', config.servers)),
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()
@@ -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: 'brew install lua-language-server (macOS); see https://luals.github.io for other platforms',
159
- install: {
160
- darwin: { command: 'brew', args: ['install', 'lua-language-server'] },
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: 'brew install llvm (macOS); sudo apt-get install clangd (Debian/Ubuntu)',
168
- install: {
169
- darwin: { command: 'brew', args: ['install', 'llvm'] },
170
- linux: { command: 'sudo', args: ['apt-get', 'install', '-y', 'clangd'] },
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
- /** Platform-resolved install spec for a registry entry, or undefined. */
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 install = entry?.install
249
+ const resolved = entry?.platforms ? serverForPlatform(entry, platform) : entry
250
+ const install = resolved?.install
198
251
  if (!install) return undefined
199
- if (Array.isArray(install.args)) return install // single-spec form
252
+ if (typeof install.command === 'string') return install // single-spec form
200
253
  return install[platform] ?? install.default
201
254
  }
202
255
 
203
- /** Display form of the install command for a registry entry. */
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 spec = installSpecFor(entry, platform)
206
- if (!spec) return entry?.installHint ?? ''
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
- /** Merge user `lsp.servers` config over the defaults (per-language overlay). */
211
- export function buildRegistry(configServers) {
212
- const registry = { ...DEFAULT_SERVERS }
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
  }
@@ -54,7 +54,10 @@ function linux(note, sound) {
54
54
  }
55
55
  }
56
56
 
57
- /** PowerShell source of the Windows toast; reads title/body from the environment. */
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
- '[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($appId).Show($toast)',
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
  /**
@@ -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
- /** @param {any} ctx @param {object} [config] */
242
- function apply(ctx, config = {}) {
243
- const notifier = createNotifier({ logger: ctx.logger })
244
- if (!notifier.supported) ctx.logger?.warn?.(`notify: system notifications are not supported on platform "${process.platform}"`)
245
- // The page delivers (as DeepSeek Harness); the system command is the fallback.
246
- const channel = createWebChannel({ fallback: (note, options) => notifier.send(note, options), logger: ctx.logger })
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 the real path, and shows even
254
- // with the window in front (the user is looking at the settings page).
255
- sendTest: () => channel.send({ ...TEST_NOTE, tag: tagFor('test', 'test'), renotify: RENOTIFY, sound: true, foreground: 'always' }),
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.close()
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, or suppressed it
13
- // on purpose (window in the foreground), is final — no fallback;
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
 
@@ -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 = []
@@ -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
- /** Parse + validate the lspServers JSON map (bad input fails activation loud). */
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 (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
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 !== 'object' || typeof entry.command !== 'string' || entry.command.trim().length === 0) {
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
  }