dsh-tg-bridge 1.3.3 → 1.3.5

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/bridge-kick.mjs CHANGED
@@ -8,7 +8,7 @@
8
8
  //
9
9
  // Exit codes: 0 = nothing to do or started, 2 = start failed.
10
10
 
11
- import { spawnSync } from 'node:child_process';
11
+ import { spawn, spawnSync } from 'node:child_process';
12
12
  import { appendFileSync, existsSync, readFileSync, openSync, closeSync, unlinkSync, writeFileSync } from 'node:fs';
13
13
  import { homedir } from 'node:os';
14
14
  import { join } from 'node:path';
@@ -21,25 +21,48 @@ const KICK_LOG = join(DIR, 'kick.log');
21
21
 
22
22
  // Shared with setup.mjs: a restart holds this lock, so a tick that lands in the
23
23
  // middle of one must not start a second poller.
24
+ //
25
+ // A live holder used to disable supervision forever: the old code started the
26
+ // bridge with spawnSync(), which blocks until the child EXITS — for a long-lived
27
+ // bridge the tick never returned, kept this lock for hours, and every later tick
28
+ // logged "restart in progress" and skipped (2026-10-24: supervisor dead from
29
+ // 18:57 while the bridge lived). Two fixes: spawn() + unref() below, and
30
+ // LOCK_MAX_AGE_MS here so even a genuinely wedged holder cannot wedge the guard.
24
31
  const LOCK = join(DIR, 'restart.lock');
32
+ const LOCK_MAX_AGE_MS = 180_000;
33
+
25
34
  function acquireLock(owner) {
26
35
  for (let attempt = 0; attempt < 2; attempt += 1) {
27
36
  try {
28
37
  const fd = openSync(LOCK, 'wx');
29
38
  writeFileSync(fd, JSON.stringify({ pid: process.pid, owner, ts: Date.now() }));
30
39
  closeSync(fd);
31
- const release = () => { try { unlinkSync(LOCK); } catch {} };
40
+ // Remove only our own lock: after a stale-lock takeover the file belongs
41
+ // to the new holder, and a late exit must not steal it.
42
+ const release = () => {
43
+ try {
44
+ if (Number(JSON.parse(readFileSync(LOCK, 'utf8')).pid) === process.pid) unlinkSync(LOCK);
45
+ } catch {}
46
+ };
32
47
  process.on('exit', release);
33
48
  return release;
34
49
  } catch (error) {
35
50
  if (error.code !== 'EEXIST') return null;
36
51
  let held = 0;
37
- try { held = Number(JSON.parse(readFileSync(LOCK, 'utf8')).pid) || 0; } catch {}
52
+ let heldTs = 0;
53
+ try {
54
+ const info = JSON.parse(readFileSync(LOCK, 'utf8'));
55
+ held = Number(info.pid) || 0;
56
+ heldTs = Number(info.ts) || 0;
57
+ } catch {}
58
+ const age = heldTs ? Date.now() - heldTs : Number.POSITIVE_INFINITY;
38
59
  let alive = false;
39
60
  if (held) {
40
61
  try { process.kill(held, 0); alive = true; } catch (e) { alive = e.code === 'EPERM'; }
41
62
  }
42
- if (alive) return null;
63
+ // Age wins over liveness: a holder that has not finished a restart in
64
+ // LOCK_MAX_AGE_MS is wedged, not busy.
65
+ if (alive && age < LOCK_MAX_AGE_MS) return null;
43
66
  try { unlinkSync(LOCK); } catch {}
44
67
  }
45
68
  }
@@ -93,25 +116,37 @@ function main() {
93
116
  const restartPending = existsSync(marker);
94
117
 
95
118
  const filePid = pidFromFile();
119
+ // Cheap path first: a live pid file is authoritative for a healthy bridge, so
120
+ // the minute tick costs one kill(0) instead of a PowerShell process scan.
121
+ if (filePid && alive(filePid)) {
122
+ if (restartPending) log(`marker present but pid ${filePid} alive`);
123
+ return 0;
124
+ }
125
+ // Only when the pid file is stale or missing: scan for a bridge that started
126
+ // without writing one.
96
127
  const live = bridgePids();
97
- if (live.length > 0 || (filePid && alive(filePid))) {
98
- if (restartPending && live.length === 0) log(`marker present but pid ${filePid} alive`);
128
+ if (live.length > 0) {
129
+ log(`pid file stale (${filePid || 'missing'}), bridge ${live.join(',')} alive — not starting`);
99
130
  return 0;
100
131
  }
101
132
 
102
133
  log(restartPending ? 'marker present and no bridge: starting' : 'no bridge: starting');
103
- const child = spawnSync(process.execPath, ['--dns-result-order=ipv4first', BRIDGE], {
104
- cwd: DIR,
105
- detached: true,
106
- stdio: 'ignore',
107
- windowsHide: true,
108
- });
109
- if (child.error) {
110
- log(`start failed: ${child.error.message}`);
134
+ // spawn(), NOT spawnSync(): spawnSync waits for the child to exit, and this
135
+ // child is meant to outlive the tick.
136
+ let child;
137
+ try {
138
+ child = spawn(process.execPath, ['--dns-result-order=ipv4first', BRIDGE], {
139
+ cwd: DIR,
140
+ detached: true,
141
+ stdio: 'ignore',
142
+ windowsHide: true,
143
+ });
144
+ } catch (error) {
145
+ log(`start failed: ${error.message}`);
111
146
  return 2;
112
147
  }
113
- try { readFileSync; } catch {}
114
- log('start issued');
148
+ child.unref();
149
+ log(`start issued pid=${child.pid ?? '?'}`);
115
150
  return 0;
116
151
  }
117
152
 
package/bridge.mjs CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  readdirSync, statSync, openSync, fsyncSync, closeSync, renameSync, writeFileSync as writeFd,
12
12
  } from 'node:fs';
13
13
  import { homedir } from 'node:os';
14
- import { join, basename } from 'node:path';
14
+ import { join, basename, isAbsolute } from 'node:path';
15
15
  import { fileURLToPath } from 'node:url';
16
16
  // Force IPv4 for every fetch. DNS returns an AAAA next to the A record, and
17
17
  // Happy Eyeballs happily picks the IPv6 path, which here costs ~5 s or dies
@@ -25,7 +25,7 @@ net.setDefaultAutoSelectFamily(false);
25
25
  import { noticeIsObsolete } from './queue-notice.mjs';
26
26
  import { immediateKind } from './immediate.mjs';
27
27
  import { writeSteerJob } from './steer-queue.mjs';
28
- import { handleControl, CONTROL_COMMANDS } from './control.mjs';
28
+ import { handleControl, CONTROL_COMMANDS, modelOf } from './control.mjs';
29
29
  import { renderPrices } from './prices.mjs';
30
30
  import { t, resolveLang } from './i18n.mjs';
31
31
 
@@ -109,6 +109,11 @@ const turnStartedAt = new Map();
109
109
  try {
110
110
  Object.assign(config, JSON.parse(readFileSync(join(DIR, 'config.json'), 'utf8')));
111
111
  } catch {}
112
+ // Old installs wrote these paths relative to the bridge directory, but turns
113
+ // run from the project cwd, where "./transcribe.py" does not exist. Resolve
114
+ // once at load so voice and vision work regardless of the topic's directory.
115
+ if (config.voice?.script && !isAbsolute(config.voice.script)) config.voice.script = join(DIR, config.voice.script);
116
+ if (config.vision?.patch && !isAbsolute(config.vision.patch)) config.vision.patch = join(DIR, config.vision.patch);
112
117
 
113
118
  const state = (() => {
114
119
  if (!existsSync(STATE)) return { offset: 0, sessions: {} };
@@ -1190,7 +1195,10 @@ ${t(lang, 'help.body')}`,
1190
1195
  api: tg,
1191
1196
  sendFile: (p, caption = '') => sendFile(chatId, threadId, p, caption),
1192
1197
  seedTopic: ensureTopicSeed,
1193
- prices: (filter) => renderPrices(filter, { model: (config.vision && config.vision.model) || 'deepseek-v4.1-flash-1017' }),
1198
+ prices: (filter) => {
1199
+ const route = modelOf(config.profile || 'headless');
1200
+ return renderPrices(filter, { model: route.model, provider: route.provider, lang });
1201
+ },
1194
1202
  reply: (out) => send(chatId, out, threadId),
1195
1203
  stopTurn: (k) => {
1196
1204
  const child = activeTurns.get(k);
@@ -1228,7 +1236,7 @@ ${t(lang, 'help.body')}`,
1228
1236
  // Turn telemetry for the log: what the agent did, how much it cost, on which
1229
1237
  // model. Without it /logs shows only "in" and "sent" — useless when asking
1230
1238
  // "why did this take 9 minutes".
1231
- const telemetry = { steps: 0, tools: 0, inputTokens: 0, cacheReadTokens: 0, model: config.primaryModel || 'deepseek-v4.1-flash-1017' };
1239
+ const telemetry = { steps: 0, tools: 0, inputTokens: 0, cacheReadTokens: 0, model: modelOf(config.profile || 'headless').model };
1232
1240
  const onEvent = (event) => {
1233
1241
  try {
1234
1242
  // The id of the session this process actually works in. A new topic has
package/cli.mjs CHANGED
@@ -143,7 +143,10 @@ function writeConfig({ userId, cwd, voice }, dryRun) {
143
143
  ...example,
144
144
  allowedUserIds: [Number(userId)],
145
145
  cwd,
146
- voice: { ...example.voice, enabled: Boolean(voice) },
146
+ // Turns run from the user's project directory, so these must be absolute:
147
+ // a relative "./transcribe.py" silently disables voice and vision.
148
+ voice: { ...example.voice, enabled: Boolean(voice), script: join(RUNTIME, 'transcribe.py') },
149
+ vision: { ...example.vision, patch: join(RUNTIME, 'patches', 'vision.yml') },
147
150
  };
148
151
  delete config._comment;
149
152
  if (dryRun) {
package/control.mjs CHANGED
@@ -17,6 +17,7 @@ const PROFILES = join(DSH_HOME, 'profiles');
17
17
  const APP = join(process.env.LOCALAPPDATA || '', 'Programs', 'DeepSeek Harness');
18
18
  const EXE = join(APP, 'DeepSeek Harness.exe');
19
19
  const DSH_CMD = join(APP, 'resources', 'runtime', 'cli', 'bin', 'dsh.cmd');
20
+ const CLI_JS = join(APP, 'resources', 'app.asar', 'dsh', 'node_modules', '@deepseek-ai', 'dsh-desktop-host', 'lib', 'cli.js');
20
21
 
21
22
  /** Live DSH app PIDs (empty when the app is not running). */
22
23
  function listPids() {
@@ -204,10 +205,16 @@ function removeBundle(profile, name) {
204
205
  /** Run `dsh plugin ...` (which forwards to pnpm) and collect its output. */
205
206
  function dshPlugin(profile, args, timeoutMs = 180000) {
206
207
  return new Promise((resolve) => {
207
- const child = spawn(DSH_CMD, ['plugin', '--profile', profile, ...args], {
208
- windowsHide: true,
209
- shell: false,
210
- });
208
+ // Spawning the .cmd shim with shell:false fails with EINVAL on modern Node.
209
+ // Run the real CLI entry through the Electron binary instead, like the
210
+ // bridge does for turns; fall back to a plain `dsh` on other platforms.
211
+ const useExe = process.platform === 'win32' && existsSync(EXE) && existsSync(join(APP, 'resources', 'app.asar'));
212
+ const child = useExe
213
+ ? spawn(EXE, ['--expose-internals', CLI_JS, 'plugin', '--profile', profile, ...args], {
214
+ windowsHide: true,
215
+ env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
216
+ })
217
+ : spawn('dsh', ['plugin', '--profile', profile, ...args], { windowsHide: true });
211
218
  let out = '';
212
219
  const timer = setTimeout(() => child.kill(), timeoutMs);
213
220
  child.stdout.setEncoding('utf8');
@@ -350,7 +357,7 @@ export const CONTROL_COMMANDS = [
350
357
  ];
351
358
 
352
359
  export async function handleControl(ctx) {
353
- const { command, arg, send, reply, log, profile, state, key, config } = ctx;
360
+ const { command, arg, send, reply, log, profile, state, key, config, lang } = ctx;
354
361
  const say = (text) => reply(text);
355
362
 
356
363
  // One command for one job: `/plugins` is both the list and the way to change
@@ -369,30 +376,37 @@ export async function handleControl(ctx) {
369
376
 
370
377
  switch (effective) {
371
378
  case '/status': {
372
- const head = modelOf('headless');
373
- const desk = modelOf('desktop');
374
- const deskPlugins = pluginsOf('desktop');
379
+ const activeProfile = config.profile || 'headless';
380
+ const head = modelOf(activeProfile);
375
381
  const session = state.sessions[key] || '(ещё нет)';
376
382
  const active = ctx.activeTurns ? ctx.activeTurns.size : 0;
377
- say(
378
- [
379
- `Голова: ${config.profile} (${head.provider}/${head.model})`,
380
- `UI: desktop (${desk.provider}/${desk.model}) — плагинов ${deskPlugins.bundles.length}, отключено ${deskPlugins.disabled.length}`,
381
- `Ветка: ${ctx.threadId || 'общая'}`,
382
- `Сессия: ${session}`,
383
- `Ходов в работе: ${active}`,
384
- `Плагины: ${deskPlugins.bundles.length} | скиллы: ${skillsOf().length}`,
385
- `DSH_HOME: ${DSH_HOME}`,
386
- ].join('\n'),
387
- );
383
+ const lines = [
384
+ `Профиль: ${activeProfile} (${head.provider}/${head.model})`,
385
+ `Ветка: ${ctx.threadId || 'общая'}`,
386
+ `Сессия: ${session}`,
387
+ `Ходов в работе: ${active}`,
388
+ `Скиллы: ${skillsOf().length}`,
389
+ `DSH_HOME: ${DSH_HOME}`,
390
+ ];
391
+ // The desktop profile only matters when this install actually has one.
392
+ if (existsSync(join(PROFILES, 'desktop', 'cordis.patch.yml'))) {
393
+ const desk = modelOf('desktop');
394
+ const deskPlugins = pluginsOf('desktop');
395
+ lines.splice(1, 0, `UI: desktop (${desk.provider}/${desk.model}) — плагинов ${deskPlugins.bundles.length}`);
396
+ }
397
+ say(lines.join('\n'));
388
398
  return true;
389
399
  }
390
400
 
391
401
  case '/models': {
392
- const head = modelOf('headless');
393
- const ids = modelsOf('headless');
402
+ const activeProfile = config.profile || 'headless';
403
+ const head = modelOf(activeProfile);
404
+ const ids = modelsOf(activeProfile);
394
405
  say(
395
- [`Модель сейчас: ${head.model}`, 'Доступные:', ...ids.map((id) => ` ${id === head.model ? '*' : ' '} ${id}`)].join('\n'),
406
+ [
407
+ ids.length > 0 ? `Модель сейчас: ${head.model}` : `В профиле ${activeProfile} нет списка моделей`,
408
+ ...(ids.length > 0 ? ['Доступные:', ...ids.map((id) => ` ${id === head.model ? '*' : ' '} ${id}`)] : []),
409
+ ].join('\n'),
396
410
  );
397
411
  return true;
398
412
  }
@@ -403,18 +417,19 @@ export async function handleControl(ctx) {
403
417
  say('Использование: /model <id> [both] (/models — список)');
404
418
  return true;
405
419
  }
406
- if (!setModel('headless', wanted)) {
407
- say(`Неизвестная модель «${wanted}». /models — список.`);
420
+ const activeProfile = config.profile || 'headless';
421
+ if (!setModel(activeProfile, wanted)) {
422
+ say(`Не смог переключить ${activeProfile} на «${wanted}»: в профиле нет строки agent-default-model. /models — список.`);
408
423
  return true;
409
424
  }
410
425
  const desktop = scope === 'both' ? setModel('desktop', wanted) : false;
411
- say(`Модель headless → ${wanted}${desktop ? ', desktop → ' + wanted : ''}. Действует со следующего хода.`);
426
+ say(`Модель ${activeProfile} → ${wanted}${desktop ? ', desktop → ' + wanted : ''}. Действует со следующего хода.`);
412
427
  return true;
413
428
  }
414
429
 
415
430
  case '/sessions': {
416
431
  const limit = Number.parseInt(arg, 10) > 0 ? Math.min(Number.parseInt(arg, 10), 20) : 8;
417
- const rows = recentSessions(config.cwd, limit);
432
+ const rows = recentSessions((state.cwd && state.cwd[key]) || config.cwd, limit);
418
433
  if (rows.length === 0) {
419
434
  say('Сессий не нашёл.');
420
435
  return true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-tg-bridge",
3
- "version": "1.3.3",
3
+ "version": "1.3.5",
4
4
  "description": "Telegram bridge for DeepSeek Harness: text, voice, photos and project-per-topic sessions without opening the desktop app",
5
5
  "type": "module",
6
6
  "main": "bridge.mjs",
package/prices.mjs CHANGED
@@ -99,49 +99,121 @@ function usd(value) {
99
99
  return `$${value < 0.01 ? value.toFixed(4) : value.toFixed(2)}`;
100
100
  }
101
101
 
102
+ const WORDS = {
103
+ en: {
104
+ prices: 'Prices',
105
+ now: 'Current',
106
+ cheap: 'Cheapest with context >= 128k:',
107
+ none: 'No models matched the filter.',
108
+ fail: 'Could not fetch prices',
109
+ unknown: 'not found in the catalog',
110
+ },
111
+ ru: {
112
+ prices: 'Цены',
113
+ now: 'Сейчас',
114
+ cheap: 'Дешёвые модели с контекстом ≥128k:',
115
+ none: 'Ничего не нашлось по фильтру.',
116
+ fail: 'Не смог получить цены',
117
+ unknown: 'в каталоге не найден',
118
+ },
119
+ zh: {
120
+ prices: '价格',
121
+ now: '当前',
122
+ cheap: '上下文 ≥128k 的最便宜模型:',
123
+ none: '没有符合筛选条件的模型。',
124
+ fail: '无法获取价格',
125
+ unknown: '目录中未找到',
126
+ },
127
+ };
128
+
102
129
  /**
103
- * Render `/prices [filter]`.
130
+ * Find a model by id. Providers prefix ids differently (`openai/gpt-4o`,
131
+ * `gpt-4o`, `unorouter/gpt-4o:free`), so exact match first, then the shortest
132
+ * id that contains the wanted token.
133
+ */
134
+ export function findModel(models, wanted) {
135
+ const needle = String(wanted ?? '').trim().toLowerCase();
136
+ if (!needle) return null;
137
+ const exact = models.find((model) => model.id.toLowerCase() === needle);
138
+ if (exact) return exact;
139
+ const matches = models.filter((model) => model.id.toLowerCase().includes(needle));
140
+ matches.sort((a, b) => a.id.length - b.id.length);
141
+ return matches[0] || null;
142
+ }
143
+
144
+ /**
145
+ * Render `/prices [filter]` from every catalog we can reach, so the answer does
146
+ * not depend on one provider: the unorouter page carries live prices for its
147
+ * own routes, models.dev covers the rest of the market. Both are merged; the
148
+ * current model is looked up in the merged list.
104
149
  * @param {string} filter optional text to match model ids
105
- * @param {{ model?: string }} options current route, for a "you are here" line
150
+ * @param {{ model?: string, provider?: string, lang?: string }} options
106
151
  */
107
152
  export async function renderPrices(filter = '', options = {}) {
153
+ const lang = WORDS[options.lang] ? options.lang : 'en';
154
+ const words = WORDS[lang];
155
+ const [page, dev] = await Promise.allSettled([
156
+ fetchText(MODELS_PAGE),
157
+ fetchText(MODELS_DEV, 30000),
158
+ ]);
159
+
108
160
  let models = [];
109
- let source = '';
110
- try {
111
- models = parseModelsPage(await fetchText(MODELS_PAGE));
112
- source = 'unorouter';
113
- } catch (error) {
114
- models = [];
161
+ const sources = [];
162
+ if (page.status === 'fulfilled') {
163
+ const parsed = parseModelsPage(page.value);
164
+ if (parsed.length > 0) {
165
+ models.push(...parsed);
166
+ sources.push('unorouter');
167
+ }
115
168
  }
116
- if (models.length === 0) {
169
+ if (dev.status === 'fulfilled') {
117
170
  try {
118
- models = parseModelsDev(JSON.parse(await fetchText(MODELS_DEV, 30000)));
119
- source = 'models.dev (страница unorouter недоступна)';
120
- } catch (error) {
121
- return `Не смог получить цены ни со страницы unorouter, ни с models.dev: ${error.message}`;
122
- }
171
+ const parsed = parseModelsDev(JSON.parse(dev.value));
172
+ if (parsed.length > 0) {
173
+ models.push(...parsed);
174
+ sources.push('models.dev');
175
+ }
176
+ } catch {}
177
+ }
178
+ if (models.length === 0) {
179
+ const reason = dev.status === 'rejected'
180
+ ? dev.reason?.message
181
+ : page.status === 'rejected'
182
+ ? page.reason?.message
183
+ : 'catalogs parsed to 0 models';
184
+ return `${words.fail}: ${reason}`;
123
185
  }
124
186
 
187
+ // The live page wins on duplicate ids; models.dev fills the rest.
188
+ const seen = new Set();
189
+ models = models.filter((model) => {
190
+ const id = model.id.toLowerCase();
191
+ if (seen.has(id)) return false;
192
+ seen.add(id);
193
+ return true;
194
+ });
195
+
125
196
  const wanted = String(filter ?? '').trim().toLowerCase();
126
- let rows = models.filter((m) => (m.context ?? 0) >= 128000 || m.context === null);
197
+ let rows = models.filter((m) => ((m.context ?? 0) >= 128000 || m.context === null) && m.inputPrice > 0);
127
198
  if (wanted) rows = rows.filter((m) => m.id.toLowerCase().includes(wanted));
128
199
  rows.sort((a, b) => a.inputPrice - b.inputPrice);
129
200
 
130
- const lines = [`Цены: ${source}`, ''];
131
- if (options.model) {
132
- const current = models.find((m) => m.id.toLowerCase().includes(String(options.model).toLowerCase()));
201
+ const lines = [`${words.prices}: ${sources.join(' + ')}`, ''];
202
+ if (options.model && options.model !== '?') {
203
+ const current = findModel(models, options.model);
204
+ const name = options.provider ? `${options.provider}/${options.model}` : options.model;
133
205
  lines.push(
134
206
  current
135
- ? `Сейчас: ${options.model} — вход ${usd(current.inputPrice)}, выход ${usd(current.outputPrice)} за 1M`
136
- : `Сейчас: ${options.model} (в каталоге не найден)`,
207
+ ? `${words.now}: ${name} — ${usd(current.inputPrice)} / ${usd(current.outputPrice)} per 1M`
208
+ : `${words.now}: ${name} (${words.unknown})`,
137
209
  ''
138
210
  );
139
211
  }
140
- lines.push('Дешёвые модели с контекстом ≥128k:');
212
+ lines.push(words.cheap);
141
213
  for (const model of rows.slice(0, 12)) {
142
214
  const context = model.context ? `${Math.round(model.context / 1000)}k` : '?';
143
215
  lines.push(`• ${model.id} — ${usd(model.inputPrice)} / ${usd(model.outputPrice)} · ${context}`);
144
216
  }
145
- if (rows.length === 0) lines.push('Ничего не нашлось по фильтру.');
217
+ if (rows.length === 0) lines.push(words.none);
146
218
  return lines.join('\n');
147
219
  }
package/setup.mjs CHANGED
@@ -286,11 +286,30 @@ async function main() {
286
286
  if (recentConflict) fail('a stale poller still owns getUpdates (409 in the last 90s) — kill it, then re-run setup');
287
287
  else ok('no 409 conflicts in the last 90s');
288
288
 
289
+ // 4. Scheduled task: point it at the GUARDED HIDDEN launcher (wscript + bridge-kick).
290
+ // Two reasons it must be the guarded supervisor and not a direct node.exe start:
291
+ // (a) a console action on an Interactive-logon task pops a window at every logon
292
+ // and repetition — wscript with window style 0 never shows one;
293
+ // (b) starting bridge.mjs directly here races the one-minute tick: both see "no
294
+ // bridge" and both start a poller (409 getUpdates Conflict). Going through
295
+ // bridge-kick.mjs means every start happens under restart.lock after a check.
296
+ const vbsPath = join(BRIDGE_DIR, 'bridge-kick-hidden.vbs');
297
+ writeFileSync(
298
+ vbsPath,
299
+ [
300
+ "' hidden launcher: DSH bridge supervisor tick (no console window)",
301
+ 'Set sh = CreateObject("WScript.Shell")',
302
+ `sh.CurrentDirectory = "${BRIDGE_DIR}"`,
303
+ `rc = sh.Run("""${NODE}"" ""${join(BRIDGE_DIR, 'bridge-kick.mjs')}""", 0, True)`,
304
+ 'WScript.Quit rc',
305
+ '',
306
+ ].join('\r\n'),
307
+ );
289
308
  const task = run('powershell', [
290
309
  '-NoProfile', '-NonInteractive', '-Command',
291
- `$a = New-ScheduledTaskAction -Execute '${NODE}' -Argument '"--dns-result-order=ipv4first" "${BRIDGE}"' -WorkingDirectory '${BRIDGE_DIR}'; Set-ScheduledTask -TaskName 'DSH-TG-Bridge' -Action $a | Out-Null; 'ok'`,
310
+ `$a = New-ScheduledTaskAction -Execute 'C:\\Windows\\System32\\wscript.exe' -Argument '//B //Nologo "${vbsPath}"'; Set-ScheduledTask -TaskName 'DSH-TG-Bridge' -Action $a | Out-Null; 'ok'`,
292
311
  ]);
293
- if ((task.stdout || '').includes('ok')) ok('scheduled task DSH-TG-Bridge now uses the same flags');
312
+ if ((task.stdout || '').includes('ok')) ok('scheduled task DSH-TG-Bridge now runs the guarded hidden supervisor');
294
313
  else warn(`scheduled task not updated: ${(task.stderr || '').trim().slice(0, 160)}`);
295
314
 
296
315
  // 5. Bot menu.