dsh-tg-bridge 0.0.0-stage → 1.0.2
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 +21 -0
- package/README.md +153 -2
- package/bot-menu.mjs +77 -0
- package/bridge-kick.mjs +118 -0
- package/bridge-restart-idle.mjs +100 -0
- package/bridge.mjs +1659 -0
- package/cli.mjs +60 -0
- package/config.example.json +47 -0
- package/control.mjs +823 -0
- package/docs/CHANGELOG.md +32 -0
- package/docs/RELEASE-CRITERIA.md +23 -0
- package/docs/ROADMAP.md +51 -0
- package/docs/STATE.md +64 -0
- package/docs/UX-ANALYSIS.md +62 -0
- package/docs/archive/DSH-ROADMAP-HANDOFF.md +120 -0
- package/docs/archive/PLAN.md +119 -0
- package/docs/archive/PROGRESS.md +94 -0
- package/install.ps1 +36 -0
- package/install.sh +32 -0
- package/package.json +48 -4
- package/patches/model-deepseek-v4.1-flash_free.yml +4 -0
- package/patches/model-glm-5.3-flash_free.yml +4 -0
- package/patches/model-kimi-k3_free.yml +4 -0
- package/patches/vision.yml +10 -0
- package/prices.mjs +147 -0
- package/restart-after-send.mjs +116 -0
- package/setup.mjs +331 -0
- package/transcribe.py +85 -0
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,154 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-tg-bridge
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
- **Кнопки «Стоп»/«Повторить», approvals** — в планах v1.1 (см. `docs/ROADMAP.md`).
|
|
21
|
+
|
|
22
|
+
## Требования
|
|
23
|
+
|
|
24
|
+
- Node.js ≥ 20
|
|
25
|
+
- Установленный DeepSeek Harness (`~/.dsh/profiles/headless`)
|
|
26
|
+
- Токен Telegram-бота от [@BotFather](https://t.me/BotFather)
|
|
27
|
+
|
|
28
|
+
## Установка
|
|
29
|
+
|
|
30
|
+
Три способа, выбери один.
|
|
31
|
+
|
|
32
|
+
**A. npm (рекомендуется)**
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npx dsh-tg-bridge@latest init # создать config.json из шаблона
|
|
36
|
+
npx dsh-tg-bridge@latest check # диагностика, ничего не меняет
|
|
37
|
+
npx dsh-tg-bridge@latest setup # установить и запустить мост
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
**B. Одной строкой (curl / PowerShell)**
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
# macOS / Linux
|
|
44
|
+
curl -fsSL https://raw.githubusercontent.com/aleksvega/dsh-tg-bridge/main/install.sh | bash
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
```powershell
|
|
48
|
+
# Windows
|
|
49
|
+
irm https://raw.githubusercontent.com/aleksvega/dsh-tg-bridge/main/install.ps1 | iex
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
**C. Вручную**
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
git clone https://github.com/aleksvega/dsh-tg-bridge.git ~/.dsh/tg-bridge
|
|
56
|
+
cd ~/.dsh/tg-bridge
|
|
57
|
+
cp config.example.json config.json
|
|
58
|
+
node cli.mjs check
|
|
59
|
+
node cli.mjs setup
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
**2. Положить токен бота в хранилище DSH**
|
|
63
|
+
|
|
64
|
+
Файл `~/.dsh/.credentials.yaml`, секция `refs`:
|
|
65
|
+
|
|
66
|
+
```yaml
|
|
67
|
+
refs:
|
|
68
|
+
DSH_TELEGRAM_BOT_TOKEN: "123456789:AA..."
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Имя переменной может быть любым с префиксом `DSH_TELEGRAM_BOT_TOKEN_` —
|
|
72
|
+
мост найдёт его сам.
|
|
73
|
+
|
|
74
|
+
**3. Создать конфиг**
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
cp config.example.json config.json
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Заполнить `allowedUserIds` — свой числовой id (узнать у
|
|
81
|
+
[@userinfobot](https://t.me/userinfobot)). Без него мост не отвечает никому:
|
|
82
|
+
это защита от чужих сообщений.
|
|
83
|
+
|
|
84
|
+
**4. Применить и проверить**
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
node setup.mjs # остановит старый мост, поднимет новый, поставит меню
|
|
88
|
+
node setup.mjs --check # только отчёт, ничего не меняет
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**5. Автозапуск (Windows)**
|
|
92
|
+
|
|
93
|
+
```powershell
|
|
94
|
+
Register-ScheduledTask -TaskName "DSH-TG-Bridge" `
|
|
95
|
+
-Action (New-ScheduledTaskAction -Execute "node" -Argument "bridge.mjs" -WorkingDirectory "$HOME\.dsh\tg-bridge") `
|
|
96
|
+
-Trigger (New-ScheduledTaskTrigger -AtLogOn) -Force
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Проверка
|
|
100
|
+
|
|
101
|
+
Напиши боту:
|
|
102
|
+
|
|
103
|
+
| Команда | Ожидание |
|
|
104
|
+
|---|---|
|
|
105
|
+
| `/doctor` | отчёт: pid, Telegram, диск, каталог, сессия |
|
|
106
|
+
| `/help` | список команд по группам |
|
|
107
|
+
| `/cd pixel-creator` | тема привязана к проекту |
|
|
108
|
+
| `/prices` | цены моделей и стоимость текущей |
|
|
109
|
+
|
|
110
|
+
## Команды
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
Работа: /cd · /new · /stop · /send · /seed
|
|
114
|
+
Сессии: /status · /sessions · /resume
|
|
115
|
+
Модели: /models · /model · /prices
|
|
116
|
+
Расширения: /skills · /plugins · /plugin · /soul
|
|
117
|
+
Диагностика: /doctor · /logs [errors|N|текст]
|
|
118
|
+
Служебное: /app · /restart · /help
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Как это работает
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
Telegram → bridge.mjs → dsh --profile headless --json → ответ
|
|
125
|
+
│
|
|
126
|
+
├─ журнал pending/ + ack (сообщения не теряются)
|
|
127
|
+
├─ очередь на тему (ходы в темах параллельны)
|
|
128
|
+
├─ прогресс + кнопки (видно, что работа идёт)
|
|
129
|
+
└─ fail-fast по квоте (free-модели как резерв)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Мост — один процесс с long-polling. На каждый ход поднимается отдельный
|
|
133
|
+
процесс CLI, который умирает после ответа; поэтому «нет процесса DSH» между
|
|
134
|
+
ходами — это норма, а не сбой.
|
|
135
|
+
|
|
136
|
+
## Безопасность
|
|
137
|
+
|
|
138
|
+
- `config.json`, `state.json`, `pending/`, логи — в `.gitignore`, в репозиторий
|
|
139
|
+
не попадают.
|
|
140
|
+
- Токен хранится в хранилище DSH, не в коде и не в конфиге.
|
|
141
|
+
- Мост отвечает только id из `allowedUserIds`.
|
|
142
|
+
|
|
143
|
+
## Документация
|
|
144
|
+
|
|
145
|
+
- [docs/STATE.md](docs/STATE.md) — что работает, известные проблемы
|
|
146
|
+
- [docs/ROADMAP.md](docs/ROADMAP.md) — что дальше
|
|
147
|
+
- [docs/UX-ANALYSIS.md](docs/UX-ANALYSIS.md) — разбор интерактива
|
|
148
|
+
- [docs/ACCEPTANCE.md](docs/ACCEPTANCE.md) — критерии приёмки
|
|
149
|
+
- [docs/CHANGELOG.md](docs/CHANGELOG.md) — история изменений
|
|
150
|
+
- [docs/RELEASE-CRITERIA.md](docs/RELEASE-CRITERIA.md) — критерии готовности релиза
|
|
151
|
+
|
|
152
|
+
## Лицензия
|
|
153
|
+
|
|
154
|
+
MIT — см. [LICENSE](LICENSE).
|
package/bot-menu.mjs
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
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
|
+
];
|
|
45
|
+
|
|
46
|
+
async function api(method, body) {
|
|
47
|
+
const res = await fetch(`https://api.telegram.org/bot${token}/${method}`, {
|
|
48
|
+
method: 'POST',
|
|
49
|
+
headers: { 'content-type': 'application/json' },
|
|
50
|
+
body: JSON.stringify(body ?? {}),
|
|
51
|
+
signal: AbortSignal.timeout(20000),
|
|
52
|
+
});
|
|
53
|
+
const json = await res.json().catch(() => ({}));
|
|
54
|
+
if (!json.ok) throw new Error(`${method}: ${json.description || res.status}`);
|
|
55
|
+
return json.result;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function main() {
|
|
59
|
+
if (!token) {
|
|
60
|
+
console.error('bot token not found in ~/.dsh/.credentials.yaml');
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
if (args.has('--delete')) {
|
|
64
|
+
await api('deleteMyCommands', {});
|
|
65
|
+
console.log('menu deleted');
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
await api('setMyCommands', { commands: COMMANDS });
|
|
69
|
+
const check = await api('getMyCommands', {});
|
|
70
|
+
console.log(`menu installed: ${check.length} commands`);
|
|
71
|
+
for (const c of check) console.log(` /${c.command} — ${c.description}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
main().catch((error) => {
|
|
75
|
+
console.error(error.message);
|
|
76
|
+
process.exit(1);
|
|
77
|
+
});
|
package/bridge-kick.mjs
ADDED
|
@@ -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}`));
|