thebird 1.2.83 → 1.2.85
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/.github/workflows/publish.yml +8 -6
- package/docs/agent-chat.js +2 -2
- package/docs/app.js +19 -14
- package/docs/kilo-fs-mirror.js +16 -14
- package/docs/kilo-http-stream.js +60 -56
- package/package.json +1 -1
- package/start-kilo.js +29 -15
|
@@ -29,13 +29,15 @@ jobs:
|
|
|
29
29
|
- name: Bump patch version
|
|
30
30
|
run: |
|
|
31
31
|
git fetch --tags --force
|
|
32
|
-
|
|
32
|
+
HIGHEST=$(git tag -l 'v[0-9]*' | sort -V | tail -1 | sed 's/^v//')
|
|
33
|
+
CURRENT=$(node -p "require('./package.json').version")
|
|
34
|
+
BASE="$HIGHEST"
|
|
35
|
+
if [ -z "$BASE" ] || [ "$(printf '%s\n%s' "$CURRENT" "$BASE" | sort -V | tail -1)" = "$CURRENT" ]; then BASE="$CURRENT"; fi
|
|
36
|
+
node -e "const p=require('./package.json');p.version='$BASE';require('fs').writeFileSync('./package.json', JSON.stringify(p,null,2)+'\n')"
|
|
37
|
+
NEW_VERSION=$(npm version patch --no-git-tag-version)
|
|
38
|
+
while git rev-parse "$NEW_VERSION" >/dev/null 2>&1; do
|
|
39
|
+
echo "tag $NEW_VERSION exists, bumping again"
|
|
33
40
|
NEW_VERSION=$(npm version patch --no-git-tag-version)
|
|
34
|
-
if git rev-parse "${NEW_VERSION}" >/dev/null 2>&1; then
|
|
35
|
-
echo "Tag ${NEW_VERSION} already exists, bumping again (attempt $i)"
|
|
36
|
-
continue
|
|
37
|
-
fi
|
|
38
|
-
break
|
|
39
41
|
done
|
|
40
42
|
git add package.json
|
|
41
43
|
git commit -m "chore: bump to ${NEW_VERSION}"
|
package/docs/agent-chat.js
CHANGED
|
@@ -91,8 +91,8 @@ function buildStream(provider) {
|
|
|
91
91
|
if (provider.type === 'gemini') {
|
|
92
92
|
return streamGemini({ model: provider.model, messages: provider.messages, tools: TOOLS, apiKey: provider.apiKey, maxOutputTokens: 8192 }).fullStream;
|
|
93
93
|
}
|
|
94
|
-
if (provider.type === 'kilo') {
|
|
95
|
-
return streamKiloHTTP({ url: provider.baseUrl, model: provider.model, messages: provider.messages });
|
|
94
|
+
if (provider.type === 'kilo' || provider.type === 'opencode') {
|
|
95
|
+
return streamKiloHTTP({ url: provider.baseUrl, model: provider.model, messages: provider.messages, providerType: provider.type });
|
|
96
96
|
}
|
|
97
97
|
const url = (provider.baseUrl || '').replace(/\/$/, '') + '/chat/completions';
|
|
98
98
|
return streamOpenAI({ url, apiKey: provider.apiKey, messages: provider.messages, model: provider.model, tools: TOOLS, maxOutputTokens: 8192 });
|
package/docs/app.js
CHANGED
|
@@ -95,6 +95,21 @@ class BirdChat extends HTMLElement {
|
|
|
95
95
|
this.setState({ providerType: type, baseUrl, model, models: [], apiKey: localStorage.getItem('provider_api_key') || '' });
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
renderBaseUrlInput() {
|
|
99
|
+
const { providerType, baseUrl } = this.state;
|
|
100
|
+
if (providerType !== 'custom' && providerType !== 'kilo' && providerType !== 'opencode') return null;
|
|
101
|
+
const ph = providerType === 'kilo' ? 'http://localhost:4780' : (providerType === 'opencode' ? 'http://localhost:4790' : 'https://your-endpoint/v1');
|
|
102
|
+
return html`<input type="text" class="tui-input" style="flex:1;min-width:140px" placeholder=${ph} value=${baseUrl}
|
|
103
|
+
onchange=${e => { localStorage.setItem('provider_base_url', e.target.value); this.setState({ baseUrl: e.target.value }); }} />`;
|
|
104
|
+
}
|
|
105
|
+
renderApiKeyInput() {
|
|
106
|
+
const { providerType, apiKey } = this.state;
|
|
107
|
+
if (providerType === 'kilo' || providerType === 'opencode') return null;
|
|
108
|
+
const provDef = PROVIDERS[providerType] || PROVIDERS.custom;
|
|
109
|
+
return html`<input id="api-key-input" type="password" class="tui-input" style="flex:1;min-width:120px" placeholder=${provDef.keyPlaceholder} value=${apiKey}
|
|
110
|
+
onchange=${e => { const v = e.target.value.trim(); localStorage.setItem('provider_api_key', v); this.setState({ apiKey: v }); if (v) this.loadModels(); }} />`;
|
|
111
|
+
}
|
|
112
|
+
|
|
98
113
|
render() {
|
|
99
114
|
const { messages, streaming, model, apiKey, models, modelsLoading, status, providerType, baseUrl, streamingText } = this.state;
|
|
100
115
|
const provDef = PROVIDERS[providerType] || PROVIDERS.custom;
|
|
@@ -108,18 +123,8 @@ class BirdChat extends HTMLElement {
|
|
|
108
123
|
<div class="tui-toolbar">
|
|
109
124
|
<label>provider:</label>
|
|
110
125
|
<select class="tui-select" onchange=${e => this.setProvider(e.target.value)}>${provOpts}</select>
|
|
111
|
-
${(
|
|
112
|
-
|
|
113
|
-
placeholder=${providerType === 'kilo' ? 'http://localhost:4780' : 'https://your-endpoint/v1'} value=${baseUrl}
|
|
114
|
-
onchange=${e => { localStorage.setItem('provider_base_url', e.target.value); this.setState({ baseUrl: e.target.value }); }} />` : ''}
|
|
115
|
-
${providerType !== 'kilo' ? html`<input id="api-key-input" type="password" class="tui-input" style="flex:1;min-width:120px"
|
|
116
|
-
placeholder=${provDef.keyPlaceholder} value=${apiKey}
|
|
117
|
-
onchange=${e => {
|
|
118
|
-
const v = e.target.value.trim();
|
|
119
|
-
localStorage.setItem('provider_api_key', v);
|
|
120
|
-
this.setState({ apiKey: v });
|
|
121
|
-
if (v) this.loadModels();
|
|
122
|
-
}} />` : ''}
|
|
126
|
+
${this.renderBaseUrlInput()}
|
|
127
|
+
${this.renderApiKeyInput()}
|
|
123
128
|
${modelsLoading
|
|
124
129
|
? html`<span class="tui-spinner"></span>`
|
|
125
130
|
: html`<select class="tui-select" value=${model}
|
|
@@ -129,7 +134,7 @@ class BirdChat extends HTMLElement {
|
|
|
129
134
|
|
|
130
135
|
<div id="msg-list" class="tui-msglist">
|
|
131
136
|
${messages.map((m, i) => html`
|
|
132
|
-
<div key=${i} class=${'tui-msg ' + m.role}>${m.content}</div>`)}
|
|
137
|
+
<div key=${i} class=${'tui-msg ' + m.role}>${typeof m.content === 'string' ? m.content : (m.content || []).filter(b => b.type === 'text').map(b => b.text).join('')}</div>`)}
|
|
133
138
|
${streaming && !streamingText && html`<div class="tui-msg assistant"><span class="tui-spinner"></span> thinking...</div>`}
|
|
134
139
|
</div>
|
|
135
140
|
|
|
@@ -152,7 +157,7 @@ class BirdChat extends HTMLElement {
|
|
|
152
157
|
const text = input?.value.trim();
|
|
153
158
|
if (!text || this.state.streaming) return;
|
|
154
159
|
const { apiKey, model, providerType, baseUrl } = this.state;
|
|
155
|
-
if (!apiKey && providerType !== 'kilo') { this.setState({ status: 'Enter an API key above.' }); return; }
|
|
160
|
+
if (!apiKey && providerType !== 'kilo' && providerType !== 'opencode') { this.setState({ status: 'Enter an API key above.' }); return; }
|
|
156
161
|
input.value = '';
|
|
157
162
|
input.style.height = 'auto';
|
|
158
163
|
const normalizedMessages = [...this.state.messages, { role: 'user', content: text }].map(m => ({
|
package/docs/kilo-fs-mirror.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
|
-
export async function mirrorFromSandbox(fsBase
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
1
|
+
export async function mirrorFromSandbox(fsBase) {
|
|
2
|
+
try {
|
|
3
|
+
const listRes = await fetch(fsBase + '/__list');
|
|
4
|
+
if (!listRes.ok) return [];
|
|
5
|
+
const relFiles = await listRes.json();
|
|
6
|
+
const snap = window.__debug.idbSnapshot || (window.__debug.idbSnapshot = {});
|
|
7
|
+
const mirrored = [];
|
|
8
|
+
for (const rel of relFiles) {
|
|
9
|
+
const r = await fetch(fsBase + '/' + rel);
|
|
10
|
+
if (!r.ok) continue;
|
|
11
|
+
const content = await r.text();
|
|
12
|
+
if (snap[rel] !== content) { snap[rel] = content; mirrored.push(rel); }
|
|
13
|
+
}
|
|
14
|
+
if (mirrored.length) { window.__debug.idbPersist?.(); window.__debug.shell?.onPreviewWrite?.(); }
|
|
15
|
+
return mirrored;
|
|
16
|
+
} catch (e) { return []; }
|
|
15
17
|
}
|
package/docs/kilo-http-stream.js
CHANGED
|
@@ -1,72 +1,76 @@
|
|
|
1
1
|
import { mirrorFromSandbox } from './kilo-fs-mirror.js';
|
|
2
2
|
|
|
3
|
-
export async function* streamKiloHTTP({ url, model, messages }) {
|
|
3
|
+
export async function* streamKiloHTTP({ url, model, messages, providerType, agent }) {
|
|
4
4
|
yield { type: 'start-step' };
|
|
5
5
|
const base = (url || 'http://localhost:4780').replace(/\/$/, '');
|
|
6
6
|
const fsBase = base.replace(/:\d+$/, ':4781');
|
|
7
|
+
const isOpencode = providerType === 'opencode';
|
|
8
|
+
const dbgKey = isOpencode ? 'opencode' : 'kilo';
|
|
7
9
|
let sessRes;
|
|
8
10
|
try { sessRes = await fetch(base + '/session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }); }
|
|
9
|
-
catch (e) { throw new Error('
|
|
10
|
-
if (!sessRes.ok) throw new Error('
|
|
11
|
+
catch (e) { throw new Error(dbgKey + ' serve not reachable at ' + base + ' — start it with: node start-kilo.js --origin ' + location.origin); }
|
|
12
|
+
if (!sessRes.ok) throw new Error('/session ' + sessRes.status + ': ' + await sessRes.text());
|
|
11
13
|
const { id: sessionId } = await sessRes.json();
|
|
12
|
-
Object.assign(window.__debug = window.__debug || {}, {
|
|
13
|
-
|
|
14
|
-
const queue = [];
|
|
15
|
-
let resolveNext = null;
|
|
16
|
-
let streamEnded = false;
|
|
17
|
-
const push = ev => { queue.push(ev); if (resolveNext) { const r = resolveNext; resolveNext = null; r(); } };
|
|
18
|
-
const textSeen = new Set();
|
|
19
|
-
const toolState = new Map();
|
|
20
|
-
|
|
21
|
-
const es = new EventSource(base + '/event');
|
|
22
|
-
es.onmessage = (ev) => {
|
|
23
|
-
try {
|
|
24
|
-
const msg = JSON.parse(ev.data);
|
|
25
|
-
if (msg.type !== 'message.part.updated') return;
|
|
26
|
-
const part = msg.properties?.part;
|
|
27
|
-
if (!part) return;
|
|
28
|
-
if (part.type === 'text' && part.messageID && !textSeen.has(part.id)) {
|
|
29
|
-
const prior = toolState.get('__text_' + part.id) || '';
|
|
30
|
-
const txt = part.text || '';
|
|
31
|
-
if (txt.length > prior.length) { push({ type: 'text-delta', textDelta: txt.slice(prior.length) }); toolState.set('__text_' + part.id, txt); }
|
|
32
|
-
} else if (part.type === 'tool') {
|
|
33
|
-
const cid = part.callID;
|
|
34
|
-
const st = part.state?.status;
|
|
35
|
-
if (st === 'running' && !toolState.has(cid)) {
|
|
36
|
-
toolState.set(cid, { name: part.tool, args: part.state.input || {} });
|
|
37
|
-
push({ type: 'tool-call', toolCallId: cid, toolName: part.tool, args: part.state.input || {} });
|
|
38
|
-
window.__debug.kilo.toolCalls.push({ id: cid, name: part.tool, args: part.state.input || {} });
|
|
39
|
-
} else if (st === 'completed' && toolState.has(cid) && !toolState.get(cid).completed) {
|
|
40
|
-
toolState.get(cid).completed = true;
|
|
41
|
-
push({ type: 'tool-result', toolCallId: cid, toolName: part.tool, args: part.state.input || {}, result: part.state.output || '' });
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
} catch (_) {}
|
|
45
|
-
};
|
|
14
|
+
Object.assign(window.__debug = window.__debug || {}, { [dbgKey]: { sessionId, url: base, fsBase, lastStatus: null } });
|
|
46
15
|
|
|
47
16
|
const userText = messages.filter(m => m.role === 'user').map(m =>
|
|
48
17
|
typeof m.content === 'string' ? m.content : (m.content || []).filter(b => b.type === 'text').map(b => b.text).join('')
|
|
49
18
|
).join('\n');
|
|
50
19
|
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if (resolveNext) { const x = resolveNext; resolveNext = null; x(); }
|
|
58
|
-
return json;
|
|
59
|
-
});
|
|
20
|
+
const modelId = model || 'x-ai/grok-code-fast-1:optimized:free';
|
|
21
|
+
const codingIntent = /\b(write|create|make|build|generate|save|file|html|css|script|app|page|code)\b/i.test(userText);
|
|
22
|
+
const agentName = agent || (codingIntent ? 'build' : 'hermes-llm');
|
|
23
|
+
const body = { parts: [{ type: 'text', text: userText }], agent: agentName };
|
|
24
|
+
if (isOpencode) body.model = { providerID: 'kilo', modelID: modelId };
|
|
25
|
+
else { body.providerID = 'kilo'; body.modelID = modelId; }
|
|
60
26
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
27
|
+
let text = '';
|
|
28
|
+
if (isOpencode) {
|
|
29
|
+
const es = new EventSource(base + '/event');
|
|
30
|
+
const textByPart = new Map();
|
|
31
|
+
const assistantMsgs = new Set();
|
|
32
|
+
let done = false;
|
|
33
|
+
const pending = [];
|
|
34
|
+
let resolveNext = null;
|
|
35
|
+
const push = ev => { pending.push(ev); if (resolveNext) { const r = resolveNext; resolveNext = null; r(); } };
|
|
36
|
+
es.onmessage = e => {
|
|
37
|
+
try {
|
|
38
|
+
const m = JSON.parse(e.data);
|
|
39
|
+
if (m.type === 'message.updated') {
|
|
40
|
+
const info = m.properties?.info;
|
|
41
|
+
if (info?.sessionID === sessionId && info.role === 'assistant') {
|
|
42
|
+
assistantMsgs.add(info.id);
|
|
43
|
+
if (info.time?.completed) { done = true; if (resolveNext) { const r = resolveNext; resolveNext = null; r(); } }
|
|
44
|
+
}
|
|
45
|
+
} else if (m.type === 'message.part.updated') {
|
|
46
|
+
const part = m.properties?.part;
|
|
47
|
+
if (part?.sessionID === sessionId && part.type === 'text' && assistantMsgs.has(part.messageID)) {
|
|
48
|
+
const prior = textByPart.get(part.id) || '';
|
|
49
|
+
const txt = part.text || '';
|
|
50
|
+
if (txt.length > prior.length) { push({ type:'text-delta', textDelta: txt.slice(prior.length) }); textByPart.set(part.id, txt); }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
} catch (_) {}
|
|
54
|
+
};
|
|
55
|
+
const msgRes = await fetch(base + '/session/' + sessionId + '/message', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
56
|
+
window.__debug[dbgKey].lastStatus = msgRes.status;
|
|
57
|
+
if (!msgRes.ok) { es.close(); throw new Error('message ' + msgRes.status + ': ' + await msgRes.text()); }
|
|
58
|
+
const deadline = Date.now() + 180000;
|
|
59
|
+
while (!done || pending.length) {
|
|
60
|
+
if (pending.length) { const ev = pending.shift(); if (ev.type === 'text-delta') text += ev.textDelta; yield ev; continue; }
|
|
61
|
+
if (Date.now() > deadline) break;
|
|
62
|
+
await new Promise(r => { resolveNext = r; setTimeout(r, 5000); });
|
|
63
|
+
}
|
|
64
|
+
es.close();
|
|
65
|
+
} else {
|
|
66
|
+
const msgRes = await fetch(base + '/session/' + sessionId + '/message', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
67
|
+
window.__debug[dbgKey].lastStatus = msgRes.status;
|
|
68
|
+
if (!msgRes.ok) throw new Error('message ' + msgRes.status + ': ' + await msgRes.text());
|
|
69
|
+
const result = await msgRes.json();
|
|
70
|
+
window.__debug[dbgKey].lastResult = result;
|
|
71
|
+
for (const tp of (result.parts || []).filter(p => p.type === 'text')) { text += tp.text; yield { type: 'text-delta', textDelta: tp.text }; }
|
|
64
72
|
}
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const mirrored = await mirrorFromSandbox(fsBase, touched);
|
|
69
|
-
window.__debug.kilo.writes = mirrored;
|
|
70
|
-
if (mirrored.length) window.refreshPreview?.();
|
|
71
|
-
yield { type: 'finish-step', finishReason: result.info?.finish || 'stop' };
|
|
73
|
+
const mirrored = await mirrorFromSandbox(fsBase);
|
|
74
|
+
if (mirrored.length) { window.__debug[dbgKey].writes = mirrored; window.refreshPreview?.(); }
|
|
75
|
+
yield { type: 'finish-step', finishReason: 'stop' };
|
|
72
76
|
}
|
package/package.json
CHANGED
package/start-kilo.js
CHANGED
|
@@ -6,14 +6,29 @@ const path = require('path');
|
|
|
6
6
|
const os = require('os');
|
|
7
7
|
const args = process.argv.slice(2);
|
|
8
8
|
const get = (f, d) => { const i = args.indexOf(f); return i >= 0 ? args[i + 1] : d; };
|
|
9
|
-
const kiloPort = get('--port', '4780');
|
|
9
|
+
const kiloPort = get('--kilo-port', '4780');
|
|
10
|
+
const ocPort = get('--opencode-port', '4790');
|
|
10
11
|
const fsPort = get('--fs-port', '4781');
|
|
11
12
|
const origin = get('--origin', 'http://localhost:8787');
|
|
12
13
|
const sandbox = path.resolve(get('--sandbox', '.sandbox'));
|
|
13
14
|
fs.mkdirSync(sandbox, { recursive: true });
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const
|
|
15
|
+
try { fs.writeFileSync(path.join(sandbox, '.gitignore'), '*\n!.gitignore\n'); } catch (e) {}
|
|
16
|
+
|
|
17
|
+
const isWin = os.platform() === 'win32';
|
|
18
|
+
const kiloBin = isWin ? process.env.USERPROFILE + '\\AppData\\Roaming\\npm\\node_modules\\@kilocode\\cli\\node_modules\\@kilocode\\cli-windows-x64\\bin\\kilo.exe' : 'kilo';
|
|
19
|
+
const ocBin = isWin ? process.env.USERPROFILE + '\\AppData\\Roaming\\npm\\node_modules\\opencode-windows-x64\\bin\\opencode.exe' : 'opencode';
|
|
20
|
+
|
|
21
|
+
const procs = [];
|
|
22
|
+
const launch = (name, bin, port) => {
|
|
23
|
+
if (args.includes('--no-' + name)) return;
|
|
24
|
+
if (!fs.existsSync(bin)) { console.log(`[${name}] skip (${bin} not found)`); return; }
|
|
25
|
+
const p = spawn(bin, ['serve', '--port', port, '--hostname', '127.0.0.1', '--cors', origin], { stdio: 'inherit', env: process.env, cwd: sandbox });
|
|
26
|
+
procs.push(p);
|
|
27
|
+
console.log(`[${name}] serve --port ${port} pid ${p.pid}`);
|
|
28
|
+
};
|
|
29
|
+
launch('kilo', kiloBin, kiloPort);
|
|
30
|
+
launch('opencode', ocBin, ocPort);
|
|
31
|
+
|
|
17
32
|
const cors = { 'Access-Control-Allow-Origin': origin, 'Access-Control-Allow-Methods': 'GET,OPTIONS', 'Access-Control-Allow-Headers': 'content-type' };
|
|
18
33
|
const srv = http.createServer((req, res) => {
|
|
19
34
|
if (req.method === 'OPTIONS') { res.writeHead(204, cors); res.end(); return; }
|
|
@@ -27,19 +42,18 @@ const srv = http.createServer((req, res) => {
|
|
|
27
42
|
else out.push(path.relative(sandbox, full).replace(/\\/g, '/'));
|
|
28
43
|
}};
|
|
29
44
|
try { walk(sandbox); } catch (e) {}
|
|
30
|
-
res.writeHead(200, { ...cors, 'Content-Type': 'application/json' });
|
|
31
|
-
res.end(JSON.stringify(out));
|
|
32
|
-
return;
|
|
45
|
+
res.writeHead(200, { ...cors, 'Content-Type': 'application/json' }); res.end(JSON.stringify(out)); return;
|
|
33
46
|
}
|
|
34
47
|
const full = path.resolve(path.join(sandbox, rel));
|
|
35
|
-
if (!full.startsWith(sandbox)) { res.writeHead(403, cors); res.end(
|
|
48
|
+
if (!full.startsWith(sandbox)) { res.writeHead(403, cors); res.end(); return; }
|
|
36
49
|
fs.readFile(full, (err, data) => {
|
|
37
|
-
if (err) { res.writeHead(404, cors); res.end(
|
|
38
|
-
const ct = { '.html':
|
|
39
|
-
res.writeHead(200, { ...cors, 'Content-Type': ct });
|
|
40
|
-
res.end(data);
|
|
50
|
+
if (err) { res.writeHead(404, cors); res.end(); return; }
|
|
51
|
+
const ct = { '.html':'text/html','.js':'application/javascript','.css':'text/css','.json':'application/json','.svg':'image/svg+xml','.md':'text/plain','.txt':'text/plain' }[path.extname(rel)] || 'application/octet-stream';
|
|
52
|
+
res.writeHead(200, { ...cors, 'Content-Type': ct }); res.end(data);
|
|
41
53
|
});
|
|
42
54
|
});
|
|
43
|
-
srv.listen(fsPort, '127.0.0.1', () => console.log(
|
|
44
|
-
|
|
45
|
-
|
|
55
|
+
srv.listen(fsPort, '127.0.0.1', () => console.log(`[fs-bridge] sandbox=${sandbox} serving http://127.0.0.1:${fsPort}`));
|
|
56
|
+
|
|
57
|
+
const stop = () => { try { srv.close(); } catch (e) {} for (const p of procs) { try { p.kill(); } catch (e) {} } process.exit(0); };
|
|
58
|
+
process.on('SIGINT', stop);
|
|
59
|
+
process.on('SIGTERM', stop);
|