dsh-tg-bridge 0.0.0-stage → 1.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aleks Vega
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,164 @@
1
- # Temporary Holding Version
1
+ # dsh-tg-bridge
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Telegram-мост для [DeepSeek Harness](https://deepseek.com): управляй агентом из
4
+ бота — текстом, голосом и фото, без открытия десктопного приложения.
5
+
6
+ Каждая тема форума = отдельный проект со своей сессией и своим рабочим
7
+ каталогом. Ответы приходят с прогрессом, кнопкой «Стоп» и копируемыми блоками
8
+ кода.
9
+
10
+ ## Что умеет
11
+
12
+ - **Текст** — обычная задача уходит агенту, ответ приходит в ту же тему.
13
+ - **Голос** — локальный faster-whisper, расшифровка показывается до ответа.
14
+ - **Фото** — уходит в модель со зрением; текст и интерфейс распознаются.
15
+ - **Проект на тему** — `/cd pixel-creator`, и все ходы идут из этого каталога.
16
+ - **Живой прогресс** — «⏳ Работаю…» с шагами, счётчиком времени и кэшем в финале.
17
+ - **Не теряет сообщения** — журнал с подтверждением и переигрыванием.
18
+ - **Fail-fast** — при исчерпанной квоте сразу переключается на free-модель.
19
+ - **`dsh-tg check`** — диагностика одной командой без изменений в системе.
20
+ - **Языки** — меню и служебные сообщения на `en`, `ru`, `zh`; `/lang` переключает язык темы, новый пользователь получает свой по `language_code` из Telegram (всё вне ru/zh → `en`).
21
+ - **Кнопки «Стоп»/«Повторить», approvals** — в планах v1.1 (см. `docs/ROADMAP.md`).
22
+
23
+ ## Требования
24
+
25
+ - Node.js ≥ 20
26
+ - Установленный DeepSeek Harness (`~/.dsh/profiles/headless`)
27
+ - Токен Telegram-бота от [@BotFather](https://t.me/BotFather)
28
+
29
+ ## Языки
30
+
31
+ - Меню (`/` в чате) — три набора: `ru` по умолчанию, `en`, `zh`.
32
+ - Язык темы: `/lang en|ru|zh`; хранится в `state.json` и переживает рестарт.
33
+ - Новый пользователь получает язык из `language_code` Telegram; всё, что не `ru`/`zh`, → `en`.
34
+ - Переведены: меню, стартовое сообщение, статус хода, ошибки, `/cd`, `/new`, `/stop`, `/lang`,
35
+ ответ на неизвестную команду. Длинные отчёты (`/status`, `/sessions`, `/models` и т. п.)
36
+ пока на русском — это следующий проход.
37
+
38
+ ## Установка
39
+
40
+ Три способа, выбери один.
41
+
42
+ **A. npm (рекомендуется)**
43
+
44
+ ```bash
45
+ npx dsh-tg-bridge@latest init # создать config.json из шаблона
46
+ npx dsh-tg-bridge@latest check # диагностика, ничего не меняет
47
+ npx dsh-tg-bridge@latest setup # установить и запустить мост
48
+ ```
49
+
50
+ **B. Одной строкой (curl / PowerShell)**
51
+
52
+ ```bash
53
+ # macOS / Linux
54
+ curl -fsSL https://raw.githubusercontent.com/aleksvega/dsh-tg-bridge/main/install.sh | bash
55
+ ```
56
+
57
+ ```powershell
58
+ # Windows
59
+ irm https://raw.githubusercontent.com/aleksvega/dsh-tg-bridge/main/install.ps1 | iex
60
+ ```
61
+
62
+ **C. Вручную**
63
+
64
+ ```bash
65
+ git clone https://github.com/aleksvega/dsh-tg-bridge.git ~/.dsh/tg-bridge
66
+ cd ~/.dsh/tg-bridge
67
+ cp config.example.json config.json
68
+ node cli.mjs check
69
+ node cli.mjs setup
70
+ ```
71
+
72
+ **2. Положить токен бота в хранилище DSH**
73
+
74
+ Файл `~/.dsh/.credentials.yaml`, секция `refs`:
75
+
76
+ ```yaml
77
+ refs:
78
+ DSH_TELEGRAM_BOT_TOKEN: "123456789:AA..."
79
+ ```
80
+
81
+ Имя переменной может быть любым с префиксом `DSH_TELEGRAM_BOT_TOKEN_` —
82
+ мост найдёт его сам.
83
+
84
+ **3. Создать конфиг**
85
+
86
+ ```bash
87
+ cp config.example.json config.json
88
+ ```
89
+
90
+ Заполнить `allowedUserIds` — свой числовой id (узнать у
91
+ [@userinfobot](https://t.me/userinfobot)). Без него мост не отвечает никому:
92
+ это защита от чужих сообщений.
93
+
94
+ **4. Применить и проверить**
95
+
96
+ ```bash
97
+ node setup.mjs # остановит старый мост, поднимет новый, поставит меню
98
+ node setup.mjs --check # только отчёт, ничего не меняет
99
+ ```
100
+
101
+ **5. Автозапуск (Windows)**
102
+
103
+ ```powershell
104
+ Register-ScheduledTask -TaskName "DSH-TG-Bridge" `
105
+ -Action (New-ScheduledTaskAction -Execute "node" -Argument "bridge.mjs" -WorkingDirectory "$HOME\.dsh\tg-bridge") `
106
+ -Trigger (New-ScheduledTaskTrigger -AtLogOn) -Force
107
+ ```
108
+
109
+ ## Проверка
110
+
111
+ Напиши боту:
112
+
113
+ | Команда | Ожидание |
114
+ |---|---|
115
+ | `/doctor` | отчёт: pid, Telegram, диск, каталог, сессия |
116
+ | `/help` | список команд по группам |
117
+ | `/cd pixel-creator` | тема привязана к проекту |
118
+ | `/prices` | цены моделей и стоимость текущей |
119
+
120
+ ## Команды
121
+
122
+ ```
123
+ Работа: /cd · /new · /stop · /send · /seed
124
+ Сессии: /status · /sessions · /resume
125
+ Модели: /models · /model · /prices
126
+ Расширения: /skills · /plugins · /plugin · /soul
127
+ Диагностика: /doctor · /logs [errors|N|текст]
128
+ Служебное: /app · /restart · /help
129
+ ```
130
+
131
+ ## Как это работает
132
+
133
+ ```
134
+ Telegram → bridge.mjs → dsh --profile headless --json → ответ
135
+ │
136
+ ├─ журнал pending/ + ack (сообщения не теряются)
137
+ ├─ очередь на тему (ходы в темах параллельны)
138
+ ├─ прогресс + кнопки (видно, что работа идёт)
139
+ └─ fail-fast по квоте (free-модели как резерв)
140
+ ```
141
+
142
+ Мост — один процесс с long-polling. На каждый ход поднимается отдельный
143
+ процесс CLI, который умирает после ответа; поэтому «нет процесса DSH» между
144
+ ходами — это норма, а не сбой.
145
+
146
+ ## Безопасность
147
+
148
+ - `config.json`, `state.json`, `pending/`, логи — в `.gitignore`, в репозиторий
149
+ не попадают.
150
+ - Токен хранится в хранилище DSH, не в коде и не в конфиге.
151
+ - Мост отвечает только id из `allowedUserIds`.
152
+
153
+ ## Документация
154
+
155
+ - [docs/STATE.md](docs/STATE.md) — что работает, известные проблемы
156
+ - [docs/ROADMAP.md](docs/ROADMAP.md) — что дальше
157
+ - [docs/UX-ANALYSIS.md](docs/UX-ANALYSIS.md) — разбор интерактива
158
+ - [docs/ACCEPTANCE.md](docs/ACCEPTANCE.md) — критерии приёмки
159
+ - [docs/CHANGELOG.md](docs/CHANGELOG.md) — история изменений
160
+ - [docs/RELEASE-CRITERIA.md](docs/RELEASE-CRITERIA.md) — критерии готовности релиза
161
+
162
+ ## Лицензия
163
+
164
+ MIT — см. [LICENSE](LICENSE).
package/bot-menu.mjs ADDED
@@ -0,0 +1,141 @@
1
+ // dsh-bot-menu — install the Telegram command menu that matches THIS bridge.
2
+ //
3
+ // Why it exists: the menu the person sees is whatever `setMyCommands` last
4
+ // wrote, and another plugin (@xmanrui/dsh-im) had set a Chinese list of 31
5
+ // commands that this bridge does not implement. A menu that lies is worse than
6
+ // no menu, so the bridge owns its own list and re-asserts it on start.
7
+ //
8
+ // Usage: node bot-menu.mjs [--delete]
9
+
10
+ import { readFileSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { homedir } from 'node:os';
13
+
14
+ const args = new Set(process.argv.slice(2));
15
+
16
+ const token = (() => {
17
+ const yaml = readFileSync(join(homedir(), '.dsh', '.credentials.yaml'), 'utf8');
18
+ const m = /^\s*(DSH_TELEGRAM_BOT_TOKEN_[A-Z0-9_]+):\s*["']?([^"'\s]+)/m.exec(yaml);
19
+ return m ? m[2] : '';
20
+ })();
21
+
22
+ /** The real, implemented surface of dsh-tg-bridge — nothing aspirational. */
23
+ const COMMANDS = [
24
+ { command: 'new', description: 'Новая сессия в этой теме' },
25
+ { command: 'cd', description: 'Привязать тему к проекту: /cd pixel-creator' },
26
+ { command: 'status', description: 'Сессии, модель, активные ходы' },
27
+ { command: 'stop', description: 'Остановить текущий ход' },
28
+ { command: 'prices', description: 'Цены моделей и что стоит текущая' },
29
+ { command: 'send', description: 'Прислать файл из проекта в чат' },
30
+ { command: 'seed', description: 'Засеять пустую тему текстом и закрепить' },
31
+ { command: 'sessions', description: 'Последние сессии' },
32
+ { command: 'resume', description: 'Продолжить сессию по id' },
33
+ { command: 'models', description: 'Список моделей' },
34
+ { command: 'model', description: 'Сменить модель: /model <id>' },
35
+ { command: 'skills', description: 'Скиллы: список или текст одного' },
36
+ { command: 'soul', description: 'Персона: показать или задать' },
37
+ { command: 'plugins', description: 'Плагины профиля' },
38
+ { command: 'plugin', description: 'add/remove/disable/enable плагина' },
39
+ { command: 'app', description: 'Перезапуск приложения: /app restart confirm' },
40
+ { command: 'restart', description: 'Перезапуск моста: /restart confirm' },
41
+ { command: 'doctor', description: 'Диагностика: мост, сеть, диск, каталог, сессия' },
42
+ { command: 'logs', description: 'Хвост лога моста: /logs 50 или /logs TG_NET' },
43
+ { command: 'help', description: 'Все команды и подсказки' },
44
+ { command: 'lang', description: 'Язык интерфейса: en, ru, zh' },
45
+ ];
46
+
47
+ /** English menu (language_code: en). Same commands, translated descriptions. */
48
+ const COMMANDS_EN = [
49
+ { command: 'new', description: 'New session in this topic' },
50
+ { command: 'cd', description: 'Bind this topic to a project: /cd pixel-creator' },
51
+ { command: 'status', description: 'Sessions, model, active turns' },
52
+ { command: 'stop', description: 'Stop the current turn' },
53
+ { command: 'prices', description: 'Model prices and the cost of the current one' },
54
+ { command: 'send', description: 'Send a file from the project into the chat' },
55
+ { command: 'seed', description: 'Seed an empty topic with text and pin it' },
56
+ { command: 'sessions', description: 'Recent sessions' },
57
+ { command: 'resume', description: 'Continue a session by id' },
58
+ { command: 'models', description: 'Available models' },
59
+ { command: 'model', description: 'Show or switch the model' },
60
+ { command: 'skills', description: 'Installed skills' },
61
+ { command: 'soul', description: 'Agent persona' },
62
+ { command: 'plugins', description: 'Installed plugins' },
63
+ { command: 'plugin', description: 'Enable or disable a plugin' },
64
+ { command: 'app', description: 'Desktop app control' },
65
+ { command: 'restart', description: 'Restart the bridge: /restart confirm' },
66
+ { command: 'doctor', description: 'Diagnostics: bridge, network, disk, directory, session' },
67
+ { command: 'logs', description: 'Bridge log tail: /logs 50 or /logs TG_NET' },
68
+ { command: 'help', description: 'All commands and hints' },
69
+ { command: 'lang', description: 'Interface language: en, ru, zh' },
70
+ ];
71
+
72
+ /** Chinese menu (language_code: zh-hans). */
73
+ const COMMANDS_ZH = [
74
+ { command: 'new', description: '在本话题开始新会话' },
75
+ { command: 'cd', description: '将本话题绑定到项目:/cd pixel-creator' },
76
+ { command: 'status', description: '会话、模型、进行中的任务' },
77
+ { command: 'stop', description: '停止当前任务' },
78
+ { command: 'prices', description: '模型价格与当前模型花费' },
79
+ { command: 'send', description: '把项目中的文件发送到聊天' },
80
+ { command: 'seed', description: '用文本初始化空话题并置顶' },
81
+ { command: 'sessions', description: '最近的会话' },
82
+ { command: 'resume', description: '按 id 继续会话' },
83
+ { command: 'models', description: '可用模型' },
84
+ { command: 'model', description: '查看或切换模型' },
85
+ { command: 'skills', description: '已安装的技能' },
86
+ { command: 'soul', description: '代理人格设定' },
87
+ { command: 'plugins', description: '已安装的插件' },
88
+ { command: 'plugin', description: '启用或停用插件' },
89
+ { command: 'app', description: '桌面应用控制' },
90
+ { command: 'restart', description: '重启桥接:/restart confirm' },
91
+ { command: 'doctor', description: '诊断:桥接、网络、磁盘、目录、会话' },
92
+ { command: 'logs', description: '桥接日志:/logs 50 或 /logs TG_NET' },
93
+ { command: 'help', description: '全部命令与提示' },
94
+ { command: 'lang', description: '界面语言:en、ru、zh' },
95
+ ];
96
+
97
+ async function api(method, body) {
98
+ const res = await fetch(`https://api.telegram.org/bot${token}/${method}`, {
99
+ method: 'POST',
100
+ headers: { 'content-type': 'application/json' },
101
+ body: JSON.stringify(body ?? {}),
102
+ signal: AbortSignal.timeout(20000),
103
+ });
104
+ const json = await res.json().catch(() => ({}));
105
+ if (!json.ok) throw new Error(`${method}: ${json.description || res.status}`);
106
+ return json.result;
107
+ }
108
+
109
+ async function main() {
110
+ if (!token) {
111
+ console.error('bot token not found in ~/.dsh/.credentials.yaml');
112
+ process.exit(1);
113
+ }
114
+ if (args.has('--delete')) {
115
+ await api('deleteMyCommands', {});
116
+ console.log('menu deleted');
117
+ return;
118
+ }
119
+ // Telegram keeps one menu per language_code, so a Chinese or English user
120
+ // sees the list in their own language instead of Russian.
121
+ await api('setMyCommands', { commands: COMMANDS });
122
+ // Telegram validates language_code against its own ISO list: 'zh-hans' is
123
+ // rejected with "invalid language code", 'zh' is accepted. Each language is
124
+ // installed independently — one refusal must not leave the others unset.
125
+ for (const [code, list] of [['en', COMMANDS_EN], ['zh', COMMANDS_ZH]]) {
126
+ try {
127
+ await api('setMyCommands', { commands: list, language_code: code });
128
+ console.log(`menu installed for ${code}: ${list.length} commands`);
129
+ } catch (error) {
130
+ console.error(`menu for ${code} failed: ${error.message}`);
131
+ }
132
+ }
133
+ const check = await api('getMyCommands', {});
134
+ console.log(`menu installed: ${check.length} commands`);
135
+ for (const c of check) console.log(` /${c.command} — ${c.description}`);
136
+ }
137
+
138
+ main().catch((error) => {
139
+ console.error(error.message);
140
+ process.exit(1);
141
+ });
@@ -0,0 +1,118 @@
1
+ // bridge-kick — one-minute supervisor tick.
2
+ //
3
+ // The DSH-TG-Bridge task only fires every five minutes, so a bridge that exited
4
+ // for a code update could wait that long before coming back. This tick is cheap
5
+ // and only starts a bridge when the pid file is missing or the process is gone;
6
+ // otherwise it exits immediately. A live pid that refuses to die (EPERM) counts
7
+ // as alive: refusing to start a second poller is what prevents 409 conflicts.
8
+ //
9
+ // Exit codes: 0 = nothing to do or started, 2 = start failed.
10
+
11
+ import { spawnSync } from 'node:child_process';
12
+ import { appendFileSync, existsSync, readFileSync, openSync, closeSync, unlinkSync, writeFileSync } from 'node:fs';
13
+ import { homedir } from 'node:os';
14
+ import { join } from 'node:path';
15
+
16
+ const DSH_HOME = process.env.DSH_HOME || join(homedir(), '.dsh');
17
+ const DIR = join(DSH_HOME, 'tg-bridge');
18
+ const BRIDGE = join(DIR, 'bridge.mjs');
19
+ const PID_FILE = join(DIR, 'bridge.pid');
20
+ const KICK_LOG = join(DIR, 'kick.log');
21
+
22
+ // Shared with setup.mjs: a restart holds this lock, so a tick that lands in the
23
+ // middle of one must not start a second poller.
24
+ const LOCK = join(DIR, 'restart.lock');
25
+ function acquireLock(owner) {
26
+ for (let attempt = 0; attempt < 2; attempt += 1) {
27
+ try {
28
+ const fd = openSync(LOCK, 'wx');
29
+ writeFileSync(fd, JSON.stringify({ pid: process.pid, owner, ts: Date.now() }));
30
+ closeSync(fd);
31
+ const release = () => { try { unlinkSync(LOCK); } catch {} };
32
+ process.on('exit', release);
33
+ return release;
34
+ } catch (error) {
35
+ if (error.code !== 'EEXIST') return null;
36
+ let held = 0;
37
+ try { held = Number(JSON.parse(readFileSync(LOCK, 'utf8')).pid) || 0; } catch {}
38
+ let alive = false;
39
+ if (held) {
40
+ try { process.kill(held, 0); alive = true; } catch (e) { alive = e.code === 'EPERM'; }
41
+ }
42
+ if (alive) return null;
43
+ try { unlinkSync(LOCK); } catch {}
44
+ }
45
+ }
46
+ return null;
47
+ }
48
+
49
+ function log(line) {
50
+ try {
51
+ appendFileSync(KICK_LOG, `${new Date().toISOString()} ${line}\n`, 'utf8');
52
+ } catch {}
53
+ }
54
+
55
+ function pidFromFile() {
56
+ try {
57
+ const pid = Number(readFileSync(PID_FILE, 'utf8').trim());
58
+ return Number.isInteger(pid) && pid > 0 ? pid : 0;
59
+ } catch {
60
+ return 0;
61
+ }
62
+ }
63
+
64
+ /** True when the pid exists; a permission error still means "alive". */
65
+ function alive(pid) {
66
+ try {
67
+ process.kill(pid, 0);
68
+ return true;
69
+ } catch (error) {
70
+ return error.code === 'EPERM';
71
+ }
72
+ }
73
+
74
+ function bridgePids() {
75
+ const r = spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command',
76
+ "Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" | Where-Object { $_.CommandLine -match 'bridge\\.mjs' } | Select-Object -ExpandProperty ProcessId"],
77
+ { encoding: 'utf8', windowsHide: true, timeout: 20000 });
78
+ return (r.stdout || '').split(/\r?\n/).map((v) => Number(v.trim())).filter((v) => Number.isInteger(v) && v > 0);
79
+ }
80
+
81
+ function main() {
82
+ if (!acquireLock('kick')) {
83
+ log('restart in progress (setup holds the lock), skipping this tick');
84
+ return 0;
85
+ }
86
+ if (!existsSync(BRIDGE)) {
87
+ log('bridge.mjs missing');
88
+ return 2;
89
+ }
90
+ // A restart marker with an empty queue means the previous process exited on
91
+ // purpose: start it again and clear the marker.
92
+ const marker = join(DIR, 'restart.pending');
93
+ const restartPending = existsSync(marker);
94
+
95
+ const filePid = pidFromFile();
96
+ const live = bridgePids();
97
+ if (live.length > 0 || (filePid && alive(filePid))) {
98
+ if (restartPending && live.length === 0) log(`marker present but pid ${filePid} alive`);
99
+ return 0;
100
+ }
101
+
102
+ 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}`);
111
+ return 2;
112
+ }
113
+ try { readFileSync; } catch {}
114
+ log('start issued');
115
+ return 0;
116
+ }
117
+
118
+ process.exit(main());
@@ -0,0 +1,100 @@
1
+ // bridge-restart-idle — restart the bridge only when no turn is running.
2
+ //
3
+ // v1 watched for a "sent chat=" log line and died before acting (pipes inside a
4
+ // detached process). v2 polls the process tree instead: a turn is running while
5
+ // a DeepSeek Harness.exe child belongs to the bridge. Restart happens only after
6
+ // several consecutive checks find no such child, so the pending reply is already
7
+ // delivered and nothing is killed mid-answer.
8
+ //
9
+ // Usage: node bridge-restart-idle.mjs [--idle-sec 12] [--max-min 20]
10
+
11
+ import { execFileSync, spawn } from 'node:child_process';
12
+ import { appendFileSync, existsSync, readFileSync } from 'node:fs';
13
+ import { homedir } from 'node:os';
14
+ import { join } from 'node:path';
15
+
16
+ const DSH_HOME = process.env.DSH_HOME || join(homedir(), '.dsh');
17
+ const DIR = join(DSH_HOME, 'tg-bridge');
18
+ const BRIDGE = join(DIR, 'bridge.mjs');
19
+ const PID_FILE = join(DIR, 'bridge.pid');
20
+ const RL = join(DIR, 'restart.log');
21
+
22
+ const argv = process.argv.slice(2);
23
+ const idleSec = Number(argv[argv.indexOf('--idle-sec') + 1]) || 12;
24
+ const maxMin = Number(argv[argv.indexOf('--max-min') + 1]) || 20;
25
+ const deadline = Date.now() + maxMin * 60000;
26
+
27
+ function log(line) {
28
+ try {
29
+ appendFileSync(RL, `${new Date().toISOString()} ${line}\n`, 'utf8');
30
+ } catch {}
31
+ }
32
+
33
+ function runJson(script) {
34
+ try {
35
+ const out = execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
36
+ encoding: 'utf8',
37
+ windowsHide: true,
38
+ timeout: 20000,
39
+ });
40
+ return (out || '').trim();
41
+ } catch {
42
+ return '';
43
+ }
44
+ }
45
+
46
+ function bridgePid() {
47
+ const raw = runJson("try { (Get-Content '" + PID_FILE.replace(/'/g, "''") + "' -Raw).Trim() } catch { '' }");
48
+ const pid = Number(raw);
49
+ return Number.isInteger(pid) && pid > 0 ? pid : 0;
50
+ }
51
+
52
+ /** Children of the bridge = the CLI process of one running turn. */
53
+ function runningTurns(pid) {
54
+ const raw = runJson(
55
+ `Get-CimInstance Win32_Process -Filter "ParentProcessId=${pid}" | Where-Object { $_.Name -like 'DeepSeek Harness*' -and $_.CommandLine -match '--profile' } | Measure-Object | Select-Object -ExpandProperty Count`
56
+ );
57
+ return Number(raw) || 0;
58
+ }
59
+
60
+ async function main() {
61
+ log(`v2 started: waiting for ${idleSec}s of idle`);
62
+ let idleSince = 0;
63
+
64
+ while (Date.now() < deadline) {
65
+ const pid = bridgePid();
66
+ if (!pid) {
67
+ log('no bridge pid; nothing to restart');
68
+ return;
69
+ }
70
+ const turns = runningTurns(pid);
71
+ if (turns > 0) {
72
+ idleSince = 0;
73
+ await new Promise((r) => setTimeout(r, 3000));
74
+ continue;
75
+ }
76
+ if (!idleSince) idleSince = Date.now();
77
+ if (Date.now() - idleSince >= idleSec * 1000) {
78
+ log(`idle ${idleSec}s with no running turn; restarting bridge ${pid}`);
79
+ try {
80
+ process.kill(pid, 'SIGTERM');
81
+ } catch (error) {
82
+ log(`kill failed: ${error.message}`);
83
+ }
84
+ await new Promise((r) => setTimeout(r, 3000));
85
+ const child = spawn(process.execPath, ['--dns-result-order=ipv4first', BRIDGE], {
86
+ cwd: DIR,
87
+ detached: true,
88
+ stdio: 'ignore',
89
+ windowsHide: true,
90
+ });
91
+ child.unref();
92
+ log(`started bridge pid ${child.pid}`);
93
+ return;
94
+ }
95
+ await new Promise((r) => setTimeout(r, 2000));
96
+ }
97
+ log('deadline reached; bridge left as is');
98
+ }
99
+
100
+ main().catch((error) => log(`fatal: ${error.message}`));