dsh-console-sentinel 0.3.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -26,7 +26,7 @@ const PLUGIN_VERSION = (() => {
26
26
  try { return createRequire(import.meta.url)('dsh-console-sentinel/package.json').version }
27
27
  catch (_) { return '0.0.0' }
28
28
  })()
29
- import { runDesktopCleanup, DEFAULT_PROTECTED_PROCESSES } from './desktop.js'
29
+ import { runDesktopCleanup, runDesktopProbe, forceCloseElevated, runElevatedClose, DEFAULT_PROTECTED_PROCESSES } from './desktop.js'
30
30
  import {
31
31
  createLedger,
32
32
  eligibleForAuto,
@@ -47,6 +47,91 @@ import {
47
47
  export const name = 'dsh-console-sentinel'
48
48
  export const inject = ['tools']
49
49
 
50
+ // ---------------------------------------------------------------------------
51
+ // Host-process guard — the SAFETY BACKSTOP for any bulk close/hide action.
52
+ //
53
+ // 🛑 2026-11-08 事故:点「关闭空壳窗口」把 DSH 客户端也关了。原因是从 ledger 里
54
+ // 取"空壳"时只按 isEmpty 过滤,没排除宿主自己的进程家族 —— 宿主(Electron /
55
+ // 其下 pwsh、node、conhost)里有些控制台窗口恰好"没有业务子进程",被当成空壳
56
+ // 一并 WM_CLOSE,连带把宿主进程树杀掉。
57
+ //
58
+ // 现在任何批量关闭/隐藏都必须先算出一个"宿主保护集":宿主进程自身 + 全部祖先
59
+ // + 全部后代 + 所有镜像名像 dsh/deepseek/electron 的进程。命中集合的窗口一律跳过。
60
+ // ---------------------------------------------------------------------------
61
+ let __hostGuardCache = { at: 0, set: null }
62
+ function hostProcessGuard(ttlMs = 8000) {
63
+ const now = Date.now()
64
+ if (__hostGuardCache.set && now - __hostGuardCache.at < ttlMs) return __hostGuardCache.set
65
+ const guard = new Set()
66
+ const self = process.pid
67
+ guard.add(self)
68
+ try {
69
+ const req = createRequire(import.meta.url)
70
+ const { execFileSync } = req('node:child_process')
71
+ // One shot: pid|ppid|name for every process.
72
+ const raw = execFileSync('powershell.exe',
73
+ ['-NoProfile', '-NonInteractive', '-Command',
74
+ "Get-CimInstance Win32_Process | ForEach-Object { '{0}|{1}|{2}' -f $_.ProcessId,$_.ParentProcessId,$_.Name }"],
75
+ { encoding: 'utf8', timeout: 15000, windowsHide: true })
76
+ const map = new Map() // pid -> { ppid, name }
77
+ for (const line of String(raw).split(/\r?\n/)) {
78
+ const parts = line.trim().split('|')
79
+ if (parts.length < 3) continue
80
+ const pid = Number(parts[0])
81
+ if (!pid) continue
82
+ map.set(pid, { ppid: Number(parts[1]) || 0, name: String(parts[2] || '') })
83
+ }
84
+ const childrenOf = new Map()
85
+ for (const [pid, info] of map) {
86
+ if (!childrenOf.has(info.ppid)) childrenOf.set(info.ppid, [])
87
+ childrenOf.get(info.ppid).push(pid)
88
+ }
89
+ const seed = new Set([self])
90
+ // 1) ancestors of self
91
+ let cur = self
92
+ const upSeen = new Set()
93
+ while (cur && map.has(cur) && !upSeen.has(cur)) {
94
+ upSeen.add(cur); seed.add(cur); cur = map.get(cur).ppid
95
+ }
96
+ // 2) every process whose image looks like DSH / Electron / DeepSeek harness
97
+ for (const [pid, info] of map) {
98
+ const n = String(info.name).toLowerCase()
99
+ if (n.indexOf('dsh') === 0 || n.indexOf('deepseek') >= 0 || n === 'electron.exe' || n === 'electron') {
100
+ seed.add(pid)
101
+ }
102
+ }
103
+ // 3) expand to ALL descendants of every seed (whole DSH tree, wherever we sit)
104
+ const stack = [...seed]
105
+ const seen = new Set(seed)
106
+ for (const p of seed) guard.add(p)
107
+ while (stack.length) {
108
+ const p = stack.pop()
109
+ for (const c of (childrenOf.get(p) || [])) {
110
+ if (!seen.has(c)) { seen.add(c); guard.add(c); stack.push(c) }
111
+ }
112
+ }
113
+ } catch (_) {
114
+ // Could not enumerate: keep at least self. Callers additionally skip
115
+ // source==='dshHost' rows, so a total enumeration failure is still safe.
116
+ }
117
+ __hostGuardCache = { at: now, set: guard }
118
+ return guard
119
+ }
120
+
121
+ // 只有"裸解释器"才允许被批量关闭:cmd / powershell / pwsh / python。
122
+ // 显式排除 mpiexec(Fluent MPI)、fluent/ansys/cx* 求解器、node(可能托管服务/中继)、
123
+ // launcher 等——这些一旦被关,会杀掉正在计算的 Fluent 或后台服务。
124
+ const SAFE_SHELL_NAMES = new Set([
125
+ 'cmd', 'cmd.exe', 'powershell', 'powershell.exe', 'pwsh', 'pwsh.exe',
126
+ 'python', 'python.exe', 'pythonw', 'pythonw.exe',
127
+ ])
128
+ function isSafeClosableShell(row) {
129
+ const raw = String((row && (row.processName || row.process)) || '').toLowerCase()
130
+ if (!SAFE_SHELL_NAMES.has(raw)) return false
131
+ if (/mpi|fluent|ansys|cx\d|solver|launcher|cae/.test(raw)) return false
132
+ return true
133
+ }
134
+
50
135
  export const Config = Schema.object({
51
136
  enabled: Schema.boolean().default(true).description(
52
137
  'Master switch. When off, no scans run and no actions are taken.',
@@ -132,6 +217,11 @@ export const Config = Schema.object({
132
217
  titles: Schema.array(Schema.string()).default([]).description(
133
218
  'Window-title substrings that are never auto-managed or bulk-closed.',
134
219
  ),
220
+ unprotect: Schema.array(Schema.string()).default([]).description(
221
+ 'Process image names whose built-in desktop-cleanup protection is lifted, ' +
222
+ 'so they CAN be closed by 一键关闭其他窗口. Managed at runtime from the ' +
223
+ 'settings panel (taskbar list → 取消保护). Never applies to the DSH host.',
224
+ ),
135
225
  }).description('Static exemption list; merged with the runtime-managed whitelist file.'),
136
226
  })
137
227
 
@@ -841,7 +931,7 @@ function registerHttpUi(ctx, deps) {
841
931
  hidden: rows.filter((row) => row.hidden).length,
842
932
  },
843
933
  windows: rows,
844
- whitelist: { processes: wl.processes, titles: wl.titles },
934
+ whitelist: { processes: wl.processes, titles: wl.titles, unprotect: wl.unprotect || [] },
845
935
  whitelistPath: DEFAULT_WHITELIST_PATH,
846
936
  protectedProcesses: [
847
937
  ...(config.cleanup?.protectedProcesses || DEFAULT_PROTECTED_PROCESSES),
@@ -905,7 +995,12 @@ function registerHttpUi(ctx, deps) {
905
995
  }))
906
996
 
907
997
  register('/hide-empty', guard(async (req, res) => {
908
- const targets = ledger.snapshot().filter((row) => row.isEmpty && !row.hidden)
998
+ const hostSet = hostProcessGuard()
999
+ const all = ledger.snapshot()
1000
+ const targets = all.filter((row) =>
1001
+ row.isEmpty && !row.hidden &&
1002
+ row.source !== 'dsh-host' && !hostSet.has(Number(row.pid)) &&
1003
+ isSafeClosableShell(row))
909
1004
  let hidden = 0
910
1005
  for (const row of targets) {
911
1006
  const out = await runWindowAction(row.hwnd, 'hide')
@@ -915,6 +1010,72 @@ function registerHttpUi(ctx, deps) {
915
1010
  wsSendJson(res, 200, { ok: true, hidden, status: statusPayload() })
916
1011
  }))
917
1012
 
1013
+ // 关闭空壳窗口:对"裸解释器空壳"发 WM_CLOSE(真正结束,不是隐藏)。
1014
+ // 三重硬保护:① 宿主全进程树 ② source=dsh-host ③ 只认 cmd/powershell/pwsh/python,
1015
+ // 绝不动 mpiexec/fluent/ansys/node —— 否则会误杀正在计算的 Fluent 或宿主。
1016
+ register('/close-empty', guard(async (req, res) => {
1017
+ const hostSet = hostProcessGuard()
1018
+ const all = ledger.snapshot()
1019
+ const targets = all.filter((row) =>
1020
+ row.isEmpty &&
1021
+ row.source !== 'dsh-host' && !hostSet.has(Number(row.pid)) &&
1022
+ isSafeClosableShell(row))
1023
+ const skipped = all.filter((row) => row.isEmpty && !targets.includes(row)).length
1024
+ let closed = 0
1025
+ for (const row of targets) {
1026
+ const out = await runWindowAction(row.hwnd, 'close')
1027
+ if (out && out.ok) closed += 1
1028
+ }
1029
+ await scan()
1030
+ wsSendJson(res, 200, { ok: true, closed, skipped, status: statusPayload() })
1031
+ }))
1032
+
1033
+ // 按软件一键关闭:对指定进程名的"全部窗口"发 WM_CLOSE(等于对该软件每个窗口按 Alt+F4),
1034
+ // 不需要逐个点。宿主/DSH/Fluent 等硬保护;提权软件走一次 UAC 提权优雅关闭。
1035
+ register('/close-process', guard(async (req, res) => {
1036
+ const body = await wsReadJsonBody(req)
1037
+ const procName = String(body.process || '').trim()
1038
+ if (!procName) return wsSendJson(res, 400, { ok: false, reason: 'no-process' })
1039
+ const target = procName.toLowerCase().replace(/\.exe$/, '')
1040
+ if (/^dsh|electron|deepseek/i.test(target)) {
1041
+ return wsSendJson(res, 200, { ok: false, reason: 'host-protected' })
1042
+ }
1043
+ let probe
1044
+ try { probe = await runDesktopProbe(config.probeTimeoutMs || 20000) }
1045
+ catch (err) { return wsSendJson(res, 200, { ok: false, reason: String((err && err.message) || err) }) }
1046
+ if (!probe || !probe.ok) return wsSendJson(res, 200, { ok: false, reason: (probe && probe.reason) || 'probe-failed' })
1047
+ const hostSet = hostProcessGuard()
1048
+ const procOf = (w) => String(w.process ?? w.Process ?? '').toLowerCase().replace(/\.exe$/, '')
1049
+ const pidOf = (w) => Number(w.pid ?? w.Pid)
1050
+ const hwndOf = (w) => Number(w.hwnd ?? w.Hwnd)
1051
+ const elevOf = (w) => Boolean(w.elevated ?? w.Elevated)
1052
+ const wins = (probe.windows || []).filter((w) => procOf(w) === target && !hostSet.has(pidOf(w)))
1053
+ if (!wins.length) return wsSendJson(res, 200, { ok: true, process: procName, total: 0, closedCount: 0, note: 'no-windows' })
1054
+ const normalH = [], elevH = []
1055
+ for (const w of wins) (elevOf(w) ? elevH : normalH).push(hwndOf(w))
1056
+ let closed = 0
1057
+ for (const h of normalH) {
1058
+ const r = await runWindowAction(h, 'close')
1059
+ if (r && r.ok) closed += 1
1060
+ }
1061
+ let elevRes = null
1062
+ if (elevH.length) {
1063
+ elevRes = await runElevatedClose(elevH, 120000)
1064
+ closed += (elevRes.closedCount || 0)
1065
+ }
1066
+ await scan()
1067
+ wsSendJson(res, 200, {
1068
+ ok: true,
1069
+ process: procName,
1070
+ total: wins.length,
1071
+ closedCount: closed,
1072
+ normal: normalH.length,
1073
+ elevated: elevH.length,
1074
+ uacCancelled: Boolean(elevRes && elevRes.reason === 'uac-denied-or-cancelled'),
1075
+ status: statusPayload(),
1076
+ })
1077
+ }))
1078
+
918
1079
  register('/window', guard(async (req, res) => {
919
1080
  const body = await wsReadJsonBody(req)
920
1081
  const hwnd = Number(body.hwnd)
@@ -922,6 +1083,26 @@ function registerHttpUi(ctx, deps) {
922
1083
  if (!hwnd || supportedActions.indexOf(action) < 0) {
923
1084
  return wsSendJson(res, 400, { ok: false, reason: 'bad-params' })
924
1085
  }
1086
+ // 单窗口操作也不能碰宿主 / Fluent / MPI(否则点一下就把 DSH 或正在算的 Fluent 关了)。
1087
+ const procHint = String(body.process || '').toLowerCase().replace(/\.exe$/, '')
1088
+ const sysLocked = procHint && (/^dsh|electron|deepseek|mpi|fluent|ansys|^cx\d|solver|cae|launcher/i.test(procHint))
1089
+ const hostSet = hostProcessGuard()
1090
+ const rec = ledger.snapshot().find((r) => Number(r.hwnd) === hwnd)
1091
+ if (sysLocked || (rec && (hostSet.has(Number(rec.pid)) || rec.source === 'dsh-host'))) {
1092
+ return wsSendJson(res, 200, { ok: false, reason: 'host-protected', hwnd, action, status: statusPayload() })
1093
+ }
1094
+ // 管理员窗口:非提权 WM_CLOSE 会被 UIPI 丢弃 → 走一次提权优雅关闭(WM_CLOSE)。
1095
+ if (action === 'close' && Boolean(body.elevated)) {
1096
+ const er = await runElevatedClose([hwnd], 120000)
1097
+ await scan()
1098
+ const ok = (er.closedCount || 0) > 0
1099
+ return wsSendJson(res, 200, {
1100
+ ok,
1101
+ hwnd, action, elevated: true,
1102
+ reason: ok ? '' : (er.reason || 'not-closed'),
1103
+ status: statusPayload(),
1104
+ })
1105
+ }
925
1106
  const result = await runWindowAction(hwnd, action)
926
1107
  await scan()
927
1108
  wsSendJson(res, 200, { ok: Boolean(result && result.ok), hwnd, action, result, status: statusPayload() })
@@ -931,33 +1112,150 @@ function registerHttpUi(ctx, deps) {
931
1112
  const body = await wsReadJsonBody(req)
932
1113
  const dryRun = Boolean(body.dryRun)
933
1114
  const wl = getWhitelist()
1115
+ const norm = (s) => String(s).toLowerCase().replace(/\.exe$/, '')
1116
+ // Effective protection = (built-in + config + whitelist) minus the
1117
+ // processes the user explicitly lifted via 取消保护. The DSH host is
1118
+ // always kept protected regardless of the unprotect list.
1119
+ const unprotect = new Set((wl.unprotect || []).map(norm))
1120
+ const baseProtected = [
1121
+ ...(config.cleanup?.protectedProcesses || DEFAULT_PROTECTED_PROCESSES),
1122
+ ...wl.processes,
1123
+ ]
1124
+ const effectiveProtected = baseProtected.filter((p) => {
1125
+ const n = norm(p)
1126
+ if (n === 'dsh desktop' || n.startsWith('dsh next') || n === 'dsh') return true // host never closable
1127
+ return !unprotect.has(n)
1128
+ })
934
1129
  const result = await runDesktopCleanup({
935
1130
  confirm: !dryRun,
936
1131
  selfPid: process.pid,
937
- protectedProcesses: [...(config.cleanup?.protectedProcesses || []), ...wl.processes],
1132
+ protectedProcesses: effectiveProtected,
938
1133
  keepTitles: [...(config.cleanup?.keepTitles || []), ...wl.titles],
1134
+ // Never let 一键清理任务栏窗口 touch the DSH host process family.
1135
+ keepPids: [...hostProcessGuard()],
939
1136
  })
940
1137
  const fmt = (w) => 'hwnd=' + w.hwnd + ' pid=' + w.pid + ' [' + (w.process || '?') + '] ' + (w.title || '').slice(0, 70)
1138
+ const detailed = (w) => ({
1139
+ hwnd: w.hwnd,
1140
+ pid: w.pid,
1141
+ process: w.process || '',
1142
+ title: w.title || '',
1143
+ icon: w.icon || '',
1144
+ })
941
1145
  wsSendJson(res, 200, {
942
1146
  ok: result.ok,
943
1147
  dryRun: result.dryRun,
944
1148
  fgHwnd: result.fgHwnd,
945
1149
  planned: (result.targets || []).map(fmt),
1150
+ plannedDetailed: (result.targets || []).map(detailed),
946
1151
  plannedCount: (result.targets || []).length,
947
1152
  kept: (result.kept || []).map((k) => ({ reason: k.keepReason, text: fmt(k) })),
948
1153
  closedCount: (result.closed || []).length,
1154
+ elevatedCount: result.elevatedCount || 0,
1155
+ elevatedUacCancelled: Boolean(result.elevatedUacCancelled),
949
1156
  reason: result.reason,
950
1157
  })
951
1158
  }))
952
1159
 
1160
+ register('/taskbar-windows', guard(async (req, res) => {
1161
+ // Enumerate every visible taskbar window, attach its exe icon (base64
1162
+ // PNG data URL) and the current whitelist / protection state so the
1163
+ // settings panel can present a checkbox list the user picks exemptions
1164
+ // from, and can lift built-in protection per process ("取消保护").
1165
+ let probe
1166
+ try {
1167
+ probe = await runDesktopProbe(config.probeTimeoutMs || 20000)
1168
+ } catch (err) {
1169
+ return wsSendJson(res, 200, { ok: false, reason: String((err && err.message) || err), windows: [] })
1170
+ }
1171
+ if (!probe || !probe.ok) {
1172
+ return wsSendJson(res, 200, { ok: false, reason: (probe && probe.reason) || 'probe-failed', windows: [] })
1173
+ }
1174
+ const wl = getWhitelist()
1175
+ const norm = (s) => String(s).toLowerCase().replace(/\.exe$/, '')
1176
+ const wlProc = new Set((wl.processes || []).map(norm))
1177
+ const wlTitle = (wl.titles || []).map((s) => String(s).toLowerCase()).filter(Boolean)
1178
+ const hardSet = new Set((config.cleanup?.protectedProcesses || DEFAULT_PROTECTED_PROCESSES).map(norm))
1179
+ const unprotectSet = new Set((wl.unprotect || []).map(norm))
1180
+ // The DSH host window itself must never be closeable — not even via 取消保护.
1181
+ const isHostProc = (p) => p === 'dsh desktop' || p.startsWith('dsh next') || p === 'dsh'
1182
+ const seen = new Set()
1183
+ const windows = []
1184
+ for (const raw of probe.windows || []) {
1185
+ const hwnd = raw.hwnd ?? raw.Hwnd
1186
+ const pid = raw.pid ?? raw.Pid
1187
+ const proc = raw.process ?? raw.Process ?? ''
1188
+ const title = raw.title ?? raw.Title ?? ''
1189
+ const icon = raw.icon ?? raw.Icon ?? ''
1190
+ const cls = raw.class ?? raw.Class ?? ''
1191
+ const procLower = norm(proc)
1192
+ const titleLower = String(title).toLowerCase()
1193
+ const byProcess = wlProc.has(procLower)
1194
+ const byTitle = !byProcess && wlTitle.some((t) => t && titleLower.indexOf(t) >= 0)
1195
+ const isHard = hardSet.has(procLower) || isHostProc(procLower)
1196
+ const isUnprotect = unprotectSet.has(procLower)
1197
+ const hostLocked = isHostProc(procLower)
1198
+ const elevated = Boolean(raw.elevated ?? raw.Elevated)
1199
+ const key = String(hwnd) + '|' + String(proc) + '|' + String(title)
1200
+ if (seen.has(key)) continue
1201
+ seen.add(key)
1202
+ windows.push({
1203
+ hwnd,
1204
+ pid,
1205
+ process: proc,
1206
+ title,
1207
+ icon,
1208
+ class: cls,
1209
+ whitelisted: byProcess || byTitle,
1210
+ whitelistedBy: byProcess ? 'process' : (byTitle ? 'title' : ''),
1211
+ // effective protection == built-in protected AND not lifted by user
1212
+ protected: (isHard && !isUnprotect) || hostLocked,
1213
+ hardProtected: isHard,
1214
+ unprotected: isHard && isUnprotect && !hostLocked,
1215
+ hostLocked,
1216
+ // Elevated rows cannot be closed by WM_CLOSE (UIPI drops it); the
1217
+ // panel offers 强制关闭(管理员) which goes through UAC + taskkill.
1218
+ elevated,
1219
+ })
1220
+ }
1221
+ wsSendJson(res, 200, { ok: true, selfElevated: Boolean(probe.selfElevated), windows })
1222
+ }))
1223
+
1224
+ // 强制关闭(管理员): UAC-elevated taskkill for windows whose owner runs
1225
+ // elevated. An unelevated WM_CLOSE is silently dropped by Windows UIPI,
1226
+ // which is why Taskmgr / iTop icons "would not close" (measured
1227
+ // 2026-11-07). One UAC prompt per batch; answering No returns
1228
+ // uac-denied-or-cancelled and changes nothing.
1229
+ register('/force-close', guard(async (req, res) => {
1230
+ const body = await wsReadJsonBody(req)
1231
+ const pids = Array.isArray(body.pids) ? body.pids : (body.pid ? [body.pid] : [])
1232
+ // Only processes the caller actually listed are accepted; forceCloseElevated
1233
+ // additionally re-verifies every pid with tasklist and blocks the DSH host.
1234
+ const allowedProcesses = Array.isArray(body.allowedProcesses) ? body.allowedProcesses : []
1235
+ const result = await forceCloseElevated(pids, {
1236
+ selfPid: process.pid,
1237
+ allowedProcesses,
1238
+ timeoutMs: 120000,
1239
+ })
1240
+ wsSendJson(res, 200, {
1241
+ ok: Boolean(result.ok),
1242
+ reason: result.reason || '',
1243
+ killedCount: result.killedCount || 0,
1244
+ total: result.total || 0,
1245
+ results: result.results || [],
1246
+ rejected: result.rejected || [],
1247
+ stderr: result.stderr || '',
1248
+ })
1249
+ }))
1250
+
953
1251
  register('/whitelist', guard(async (req, res) => {
954
1252
  if ((req.method || 'GET').toUpperCase() === 'GET') {
955
1253
  const wl = getWhitelist()
956
- return wsSendJson(res, 200, { ok: true, processes: wl.processes, titles: wl.titles, path: DEFAULT_WHITELIST_PATH })
1254
+ return wsSendJson(res, 200, { ok: true, processes: wl.processes, titles: wl.titles, unprotect: wl.unprotect || [], path: DEFAULT_WHITELIST_PATH })
957
1255
  }
958
1256
  const body = await wsReadJsonBody(req)
959
1257
  const act = String(body.action || 'list')
960
- const kind = body.kind === 'title' ? 'titles' : 'processes'
1258
+ const kind = body.kind === 'title' ? 'titles' : (body.kind === 'unprotect' ? 'unprotect' : 'processes')
961
1259
  const items = Array.isArray(body.items) ? body.items : []
962
1260
  let wl = getWhitelist()
963
1261
  if (act === 'add') {
@@ -973,7 +1271,7 @@ function registerHttpUi(ctx, deps) {
973
1271
  setWhitelist(wl)
974
1272
  ctx.emit('console-sentinel/whitelist', { action: act, kind })
975
1273
  }
976
- wsSendJson(res, 200, { ok: true, action: act, processes: wl.processes, titles: wl.titles, path: DEFAULT_WHITELIST_PATH })
1274
+ wsSendJson(res, 200, { ok: true, action: act, processes: wl.processes, titles: wl.titles, unprotect: wl.unprotect || [], path: DEFAULT_WHITELIST_PATH })
977
1275
  }))
978
1276
  })
979
1277
  } catch (error) {
package/lib/probe.js CHANGED
@@ -72,7 +72,7 @@ $cb = [PInvoke.W32+EnumWindowsProc]{
72
72
  if ($cls -eq 'CASCADIA_HOSTING_WINDOW_CLASS' -or $cls -eq 'PseudoConsoleWindow' -or $cls -eq 'ConsoleWindowClass') {
73
73
  $t = [System.Text.StringBuilder]::new(512); $null = [PInvoke.W32]::GetWindowText($hWnd, $t, 512)
74
74
  $procid = 0; $null = [PInvoke.W32]::GetWindowThreadProcessId($hWnd, [ref]$procid)
75
- $global:__sentinelWins += [PSCustomObject]@{ Hwnd = $hWnd.ToInt64(); Class = $cls; Title = $t.ToString(); PID = [int]$procid; Visible = [PInvoke.W32]::IsWindowVisible($hWnd) }
75
+ $global:__sentinelWins += [PSCustomObject]@{ Hwnd = $hWnd.ToInt64(); Class = $cls; Title = $t.ToString(); PID = [int]$procid; Visible = [PInvoke.W32]::IsWindowVisible($hWnd); IconPath = [string]::Empty }
76
76
  }
77
77
  return $true
78
78
  }
@@ -127,6 +127,7 @@ foreach ($pid_ in $consolePids) {
127
127
  if ($ppid -and $byPid.ContainsKey([int]$ppid)) { $pName = $byPid[[int]$ppid].Name }
128
128
  $kids = @()
129
129
  if ($childMap.ContainsKey([int]$pid_)) { $kids = @($childMap[[int]$pid_]) }
130
+
130
131
  $procs[[string]$pid_] = @{
131
132
  Name = $p.Name
132
133
  CpuMs = [int64]($p.TotalProcessorTime.TotalMilliseconds)
package/lib/whitelist.js CHANGED
@@ -27,23 +27,29 @@ function normItems(arr) {
27
27
  }
28
28
 
29
29
  export function emptyWhitelist() {
30
- return { processes: [], titles: [] }
30
+ return { processes: [], titles: [], unprotect: [] }
31
31
  }
32
32
 
33
33
  export function sanitizeWhitelist(raw) {
34
34
  const r = raw && typeof raw === 'object' ? raw : {}
35
- return { processes: normItems(r.processes), titles: normItems(r.titles) }
35
+ return {
36
+ processes: normItems(r.processes),
37
+ titles: normItems(r.titles),
38
+ unprotect: normItems(r.unprotect),
39
+ }
36
40
  }
37
41
 
38
42
  export function mergeWhitelists(...lists) {
39
43
  const processes = []
40
44
  const titles = []
45
+ const unprotect = []
41
46
  for (const l of lists) {
42
47
  const c = sanitizeWhitelist(l)
43
48
  for (const p of c.processes) if (!processes.includes(p)) processes.push(p)
44
49
  for (const t of c.titles) if (!titles.includes(t)) titles.push(t)
50
+ for (const u of c.unprotect) if (!unprotect.includes(u)) unprotect.push(u)
45
51
  }
46
- return { processes, titles }
52
+ return { processes, titles, unprotect }
47
53
  }
48
54
 
49
55
  export function loadWhitelist(file = DEFAULT_WHITELIST_PATH) {
@@ -62,7 +68,7 @@ export function saveWhitelist(data, file = DEFAULT_WHITELIST_PATH) {
62
68
  return clean
63
69
  }
64
70
 
65
- // kind is the canonical plural key: 'processes' | 'titles'.
71
+ // kind is the canonical plural key: 'processes' | 'titles' | 'unprotect'.
66
72
  export function addWhitelistItems(data, kind, items) {
67
73
  const base = sanitizeWhitelist(data)
68
74
  return mergeWhitelists(base, { [kind]: items })
@@ -70,9 +76,9 @@ export function addWhitelistItems(data, kind, items) {
70
76
 
71
77
  export function removeWhitelistItems(data, kind, items) {
72
78
  const base = sanitizeWhitelist(data)
73
- const key = kind === 'titles' ? 'titles' : 'processes'
79
+ const key = kind === 'titles' ? 'titles' : (kind === 'unprotect' ? 'unprotect' : 'processes')
74
80
  const drop = new Set(normItems(items).map((s) => s.toLowerCase()))
75
- const out = { processes: base.processes, titles: base.titles }
81
+ const out = { processes: base.processes, titles: base.titles, unprotect: base.unprotect }
76
82
  out[key] = base[key].filter((v) => !drop.has(v.toLowerCase()))
77
83
  return out
78
84
  }
package/package.json CHANGED
@@ -1,82 +1,90 @@
1
- {
2
- "name": "dsh-console-sentinel",
3
- "version": "0.3.0",
4
- "description": "DSH console window sentinel - auto-hides idle AI/script console windows on Windows",
5
- "license": "MIT",
6
- "repository": {
7
- "type": "git",
8
- "url": "git+https://github.com/loyalchiiina/dsh-console-sentinel.git"
9
- },
10
- "homepage": "https://github.com/loyalchiiina/dsh-console-sentinel#readme",
11
- "bugs": {
12
- "url": "https://github.com/loyalchiiina/dsh-console-sentinel/issues"
13
- },
14
- "type": "module",
15
- "main": "lib/index.js",
16
- "exports": {
17
- ".": "./lib/index.js",
18
- "./client": "./lib/client.js",
19
- "./package.json": "./package.json",
20
- "./locale/*.json": "./locale/*.json",
21
- "./icon": "./icon.svg"
22
- },
23
- "files": [
24
- "lib",
25
- "lib/**/*.js",
26
- "cordis.patch.yml",
27
- "LICENSE",
28
- "README.md",
29
- "README.zh.md",
30
- "locale/*.json",
31
- "icon.svg",
32
- "README-zh.md",
33
- "README-es.md",
34
- "README-pt.md",
35
- "README-hi.md",
36
- "CHANGELOG.md"
37
- ],
38
- "engines": {
39
- "node": "^22.19.0 || >=24.0.0"
40
- },
41
- "keywords": [
42
- "dsh",
43
- "dsh-plugin",
44
- "deepseek-harness",
45
- "console",
46
- "window",
47
- "powershell",
48
- "windows"
49
- ],
50
- "peerDependencies": {
51
- "@deepseek-ai/cordis": ">=4.0.2",
52
- "@deepseek-ai/dsh-tools": ">=0.1.5-rc.2",
53
- "@deepseek-ai/schemastery": ">=3.18.0"
54
- },
55
- "peerDependenciesMeta": {
56
- "@deepseek-ai/cordis": {
57
- "optional": false
58
- },
59
- "@deepseek-ai/dsh-tools": {
60
- "optional": false
61
- },
62
- "@deepseek-ai/schemastery": {
63
- "optional": false
64
- }
65
- },
66
- "dsh": {
67
- "bundle": {
68
- "patch": "./cordis.patch.yml"
69
- },
70
- "client": {
71
- "inject": [
72
- "@deepseek-ai/dsh-client-connection",
73
- "@deepseek-ai/dsh-client-runtime",
74
- "@deepseek-ai/dsh-client-ui-settings",
75
- "@deepseek-ai/dsh-client-ui-slots",
76
- "@deepseek-ai/dsh-client-locale"
77
- ],
78
- "platform": "web"
79
- }
80
- },
81
- "packageManager": "pnpm@11.7.0"
82
- }
1
+ {
2
+ "name": "dsh-console-sentinel",
3
+ "version": "1.0.0",
4
+ "description": "DSH console sentinel for Windows — one-click cleanup of taskbar windows and empty console shells. Graceful WM_CLOSE (= Alt+F4) per window or whole app, UAC-elevated close for admin apps, icon-grid and detail views, preview-before-execute, persistent whitelist, and hard protection for the DSH host and Fluent/MPI solvers.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/loyalchiiina/dsh-console-sentinel.git"
9
+ },
10
+ "homepage": "https://github.com/loyalchiiina/dsh-console-sentinel#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/loyalchiiina/dsh-console-sentinel/issues"
13
+ },
14
+ "type": "module",
15
+ "main": "lib/index.js",
16
+ "exports": {
17
+ ".": "./lib/index.js",
18
+ "./client": "./lib/client.js",
19
+ "./package.json": "./package.json",
20
+ "./locale/*.json": "./locale/*.json",
21
+ "./icon": "./icon.svg"
22
+ },
23
+ "files": [
24
+ "lib",
25
+ "lib/**/*.js",
26
+ "cordis.patch.yml",
27
+ "LICENSE",
28
+ "README.md",
29
+ "README.zh.md",
30
+ "README-zh.md",
31
+ "README.es.md",
32
+ "README.pt.md",
33
+ "README.hi.md",
34
+ "CHANGELOG.md",
35
+ "icon.svg",
36
+ "docs/screenshots/*.png",
37
+ "locale/*.json"
38
+ ],
39
+ "engines": {
40
+ "node": "^22.19.0 || >=24.0.0"
41
+ },
42
+ "keywords": [
43
+ "dsh",
44
+ "dsh-plugin",
45
+ "deepseek-harness",
46
+ "console",
47
+ "window",
48
+ "taskbar",
49
+ "close-window",
50
+ "alt-f4",
51
+ "wm-close",
52
+ "uac",
53
+ "elevated",
54
+ "powershell",
55
+ "process-manager",
56
+ "windows"
57
+ ],
58
+ "peerDependencies": {
59
+ "@deepseek-ai/cordis": ">=4.0.2",
60
+ "@deepseek-ai/dsh-tools": ">=0.1.5-rc.2",
61
+ "@deepseek-ai/schemastery": ">=3.18.0"
62
+ },
63
+ "peerDependenciesMeta": {
64
+ "@deepseek-ai/cordis": {
65
+ "optional": false
66
+ },
67
+ "@deepseek-ai/dsh-tools": {
68
+ "optional": false
69
+ },
70
+ "@deepseek-ai/schemastery": {
71
+ "optional": false
72
+ }
73
+ },
74
+ "dsh": {
75
+ "bundle": {
76
+ "patch": "./cordis.patch.yml"
77
+ },
78
+ "client": {
79
+ "inject": [
80
+ "@deepseek-ai/dsh-client-connection",
81
+ "@deepseek-ai/dsh-client-runtime",
82
+ "@deepseek-ai/dsh-client-ui-settings",
83
+ "@deepseek-ai/dsh-client-ui-slots",
84
+ "@deepseek-ai/dsh-client-locale"
85
+ ],
86
+ "platform": "web"
87
+ }
88
+ },
89
+ "packageManager": "pnpm@11.7.0"
90
+ }