@chatpanel/bridge 0.10.24 → 0.10.27
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/README.md +56 -0
- package/package.json +1 -1
- package/scripts/test-codex-httpie.sh +35 -0
- package/src/api-compat.js +292 -0
- package/src/connectors.js +100 -0
- package/src/engines/antigravity.js +2 -2
- package/src/engines/codex.js +2 -2
- package/src/env.js +9 -2
- package/src/proc.js +76 -6
- package/src/runs.js +49 -0
- package/src/server.js +278 -7
package/README.md
CHANGED
|
@@ -87,6 +87,11 @@ npm start # → http://127.0.0.1:4319
|
|
|
87
87
|
|--------|------|---------|
|
|
88
88
|
| `GET` | `/health` | `{ ok, version, agents:[{id,label,available,reason}] }` |
|
|
89
89
|
| `POST` | `/chat` | SSE stream — body `{ agent, system, options, messages }` |
|
|
90
|
+
| `GET` | `/v1/models` | OpenAI-compatible list of available local agents |
|
|
91
|
+
| `POST` | `/v1/chat/completions` | OpenAI-compatible Chat Completions (streaming and non-streaming) |
|
|
92
|
+
| `POST` | `/v1/completions` | OpenAI-compatible legacy text Completions (streaming and non-streaming) |
|
|
93
|
+
| `POST` | `/v1/responses` | OpenAI-compatible Responses API (streaming and non-streaming) |
|
|
94
|
+
| `POST` | `/v1/messages` | Anthropic-compatible Messages API (streaming and non-streaming) |
|
|
90
95
|
|
|
91
96
|
`/chat` streams Server-Sent Events: `{type:'delta',text}` as the answer is
|
|
92
97
|
generated, `{type:'tool',name,summary}` / `{type:'status'}` for activity, and a
|
|
@@ -102,6 +107,57 @@ final `{type:'done'}` (or `{type:'error',error}`).
|
|
|
102
107
|
}
|
|
103
108
|
```
|
|
104
109
|
|
|
110
|
+
### OpenAI and Anthropic SDK compatibility
|
|
111
|
+
|
|
112
|
+
The compatibility routes run a local coding agent; they do not call a provider
|
|
113
|
+
API. Use the per-install token from `~/.chatpanel/bridge-token` as the SDK API
|
|
114
|
+
key. The token protects endpoints that can start local processes, so do not put
|
|
115
|
+
it in browser-delivered code or share it.
|
|
116
|
+
|
|
117
|
+
For OpenAI clients, set the base URL to `http://127.0.0.1:4319/v1` and use an
|
|
118
|
+
installed agent id such as `codex` as the model:
|
|
119
|
+
|
|
120
|
+
```js
|
|
121
|
+
import OpenAI from 'openai';
|
|
122
|
+
import { readFileSync } from 'node:fs';
|
|
123
|
+
|
|
124
|
+
const client = new OpenAI({
|
|
125
|
+
apiKey: readFileSync(`${process.env.HOME}/.chatpanel/bridge-token`, 'utf8').trim(),
|
|
126
|
+
baseURL: 'http://127.0.0.1:4319/v1',
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const result = await client.responses.create({
|
|
130
|
+
model: 'codex',
|
|
131
|
+
input: 'Explain this project in three bullets.',
|
|
132
|
+
});
|
|
133
|
+
console.log(result.output_text);
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`client.chat.completions.create(...)` and legacy `client.completions.create(...)`
|
|
137
|
+
work through the same base URL. For the Anthropic SDK, use
|
|
138
|
+
`http://127.0.0.1:4319` as its base URL and the same token as its API key;
|
|
139
|
+
requests to `/v1/messages` can also use `model: "codex"`.
|
|
140
|
+
|
|
141
|
+
The model field selects the local agent. `codex/gpt-5.5`, for example, selects
|
|
142
|
+
the Codex agent and passes `gpt-5.5` as its CLI model override. Advanced bridge
|
|
143
|
+
settings can be supplied through an extra `chatpanel` request object:
|
|
144
|
+
|
|
145
|
+
```jsonc
|
|
146
|
+
{
|
|
147
|
+
"model": "codex",
|
|
148
|
+
"messages": [{ "role": "user", "content": "Fix the failing tests." }],
|
|
149
|
+
"chatpanel": {
|
|
150
|
+
"working_dir": "/absolute/path/to/project",
|
|
151
|
+
"permission_mode": "acceptEdits",
|
|
152
|
+
"use_local_config": true
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
The adapter currently covers text conversations. Provider-hosted function tools,
|
|
158
|
+
stored responses, log probabilities, and exact token accounting are not
|
|
159
|
+
available; unsupported tool requests return HTTP 400, and usage counts are zero.
|
|
160
|
+
|
|
105
161
|
## Safety
|
|
106
162
|
|
|
107
163
|
- Binds to `127.0.0.1` only; CORS accepts the extension origin and localhost.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/bridge",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.27",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Local bridge that exposes the AI coding agents installed on your machine \u2014 Claude Code (CLI), Codex (CLI), and Antigravity CLI (formerly Gemini CLI, which remains available for business/enterprise) \u2014 to the ChatPanel Chrome extension over a localhost SSE endpoint. Bring your own agent.",
|
|
6
6
|
"keywords": [
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
bridge_url="${CHATPANEL_BRIDGE_URL:-http://127.0.0.1:4319}"
|
|
5
|
+
token_file="${CHATPANEL_BRIDGE_TOKEN_FILE:-${HOME}/.chatpanel/bridge-token}"
|
|
6
|
+
prompt="${*:-Reply with exactly: httpie-codex-ok}"
|
|
7
|
+
|
|
8
|
+
if ! command -v http >/dev/null 2>&1; then
|
|
9
|
+
echo 'HTTPie is required. Install it with: brew install httpie' >&2
|
|
10
|
+
exit 1
|
|
11
|
+
fi
|
|
12
|
+
|
|
13
|
+
if [[ ! -r "$token_file" ]]; then
|
|
14
|
+
echo "Bridge token is not readable: $token_file" >&2
|
|
15
|
+
exit 1
|
|
16
|
+
fi
|
|
17
|
+
|
|
18
|
+
bridge_token="$(tr -d '\r\n' < "$token_file")"
|
|
19
|
+
if [[ -z "$bridge_token" ]]; then
|
|
20
|
+
echo "Bridge token is empty: $token_file" >&2
|
|
21
|
+
exit 1
|
|
22
|
+
fi
|
|
23
|
+
|
|
24
|
+
response="$({
|
|
25
|
+
http --check-status --ignore-stdin --body POST "$bridge_url/v1/responses" \
|
|
26
|
+
Authorization:"Bearer $bridge_token" \
|
|
27
|
+
model=codex \
|
|
28
|
+
input="$prompt"
|
|
29
|
+
})"
|
|
30
|
+
|
|
31
|
+
if command -v jq >/dev/null 2>&1; then
|
|
32
|
+
jq -r '.output[]?.content[]? | select(.type == "output_text") | .text' <<< "$response"
|
|
33
|
+
else
|
|
34
|
+
printf '%s\n' "$response"
|
|
35
|
+
fi
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// OpenAI + Anthropic wire-format adapters for ChatPanel's local agent engines.
|
|
2
|
+
//
|
|
3
|
+
// This intentionally implements the text conversation subset shared by coding
|
|
4
|
+
// agents. Provider-hosted features (token accounting, stored responses, remote
|
|
5
|
+
// function tools, logprobs, etc.) cannot be reproduced by a local CLI runner and
|
|
6
|
+
// are rejected when accepting them would produce misleading behavior.
|
|
7
|
+
|
|
8
|
+
import { randomUUID } from 'node:crypto';
|
|
9
|
+
|
|
10
|
+
const AGENT_IDS = new Set(['claude', 'codex', 'antigravity', 'pi', 'opencode', 'kiro', 'copilot', 'deepseek']);
|
|
11
|
+
|
|
12
|
+
export class CompatError extends Error {
|
|
13
|
+
constructor(message, status = 400, param = null) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.status = status;
|
|
16
|
+
this.param = param;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function textOf(content) {
|
|
21
|
+
if (content == null) return '';
|
|
22
|
+
if (typeof content === 'string') return content;
|
|
23
|
+
if (!Array.isArray(content)) return String(content);
|
|
24
|
+
return content.map((part) => {
|
|
25
|
+
if (typeof part === 'string') return part;
|
|
26
|
+
if (!part || typeof part !== 'object') return '';
|
|
27
|
+
if (typeof part.text === 'string') return part.text;
|
|
28
|
+
if (typeof part.content === 'string') return part.content;
|
|
29
|
+
if (part.type === 'tool_result') return textOf(part.content);
|
|
30
|
+
return '';
|
|
31
|
+
}).filter(Boolean).join('\n');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function assertTextContent(content, param) {
|
|
35
|
+
if (content == null || typeof content === 'string') return;
|
|
36
|
+
if (!Array.isArray(content)) throw new CompatError(`${param} must contain text`, 400, param);
|
|
37
|
+
for (const part of content) {
|
|
38
|
+
if (typeof part === 'string') continue;
|
|
39
|
+
const type = part?.type;
|
|
40
|
+
if (part && typeof part === 'object' && (!type || ['text', 'input_text', 'output_text'].includes(type)) && typeof part.text === 'string') continue;
|
|
41
|
+
if (type === 'tool_result') {
|
|
42
|
+
assertTextContent(part.content, param);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
throw new CompatError(`${param} contains unsupported non-text content`, 400, param);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function normalizeMessages(items = []) {
|
|
50
|
+
return items.map((item) => ({
|
|
51
|
+
role: item?.role === 'assistant' ? 'assistant' : 'user',
|
|
52
|
+
content: textOf(item?.content),
|
|
53
|
+
})).filter((item) => item.content);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function resolveTarget(model, chatpanel = {}) {
|
|
57
|
+
const requested = String(model || 'codex').trim() || 'codex';
|
|
58
|
+
const slash = requested.indexOf('/');
|
|
59
|
+
const prefix = slash < 0 ? requested : requested.slice(0, slash);
|
|
60
|
+
const explicitAgent = String(chatpanel.agent || '').trim();
|
|
61
|
+
const agent = explicitAgent || (AGENT_IDS.has(prefix) ? prefix : 'codex');
|
|
62
|
+
if (!AGENT_IDS.has(agent)) throw new CompatError(`Unknown ChatPanel agent "${agent}"`, 400, 'model');
|
|
63
|
+
|
|
64
|
+
let engineModel = String(chatpanel.model || '').trim();
|
|
65
|
+
if (!engineModel && slash >= 0 && AGENT_IDS.has(prefix)) engineModel = requested.slice(slash + 1);
|
|
66
|
+
if (!engineModel && !AGENT_IDS.has(requested)) engineModel = requested;
|
|
67
|
+
|
|
68
|
+
const permissionMode = chatpanel.permission_mode || chatpanel.permissionMode || 'default';
|
|
69
|
+
if (!['default', 'acceptEdits', 'bypassPermissions'].includes(permissionMode)) {
|
|
70
|
+
throw new CompatError('chatpanel.permission_mode must be default, acceptEdits, or bypassPermissions', 400, 'chatpanel.permission_mode');
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
agent,
|
|
74
|
+
requestedModel: requested,
|
|
75
|
+
options: {
|
|
76
|
+
workingDir: chatpanel.working_dir || chatpanel.workingDir || process.env.CHATPANEL_API_WORKING_DIR || '',
|
|
77
|
+
permissionMode,
|
|
78
|
+
useLocalConfig: chatpanel.use_local_config ?? chatpanel.useLocalConfig ?? true,
|
|
79
|
+
...(engineModel ? { model: engineModel } : {}),
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function parseChatCompletion(body = {}) {
|
|
85
|
+
if (!Array.isArray(body.messages) || !body.messages.length) {
|
|
86
|
+
throw new CompatError('messages must be a non-empty array', 400, 'messages');
|
|
87
|
+
}
|
|
88
|
+
if (body.tools?.length) throw new CompatError('OpenAI tool calling is not supported by the bridge adapter', 400, 'tools');
|
|
89
|
+
for (const message of body.messages) {
|
|
90
|
+
if (!['system', 'developer', 'user', 'assistant'].includes(message?.role)) {
|
|
91
|
+
throw new CompatError(`Unsupported message role "${message?.role || ''}"`, 400, 'messages');
|
|
92
|
+
}
|
|
93
|
+
assertTextContent(message.content, 'messages');
|
|
94
|
+
}
|
|
95
|
+
const system = body.messages
|
|
96
|
+
.filter((m) => m?.role === 'system' || m?.role === 'developer')
|
|
97
|
+
.map((m) => textOf(m.content)).filter(Boolean).join('\n\n');
|
|
98
|
+
const messages = normalizeMessages(body.messages.filter((m) => m?.role !== 'system' && m?.role !== 'developer'));
|
|
99
|
+
if (!messages.length) throw new CompatError('messages must contain user or assistant text', 400, 'messages');
|
|
100
|
+
return { ...resolveTarget(body.model, body.chatpanel), system, messages, stream: body.stream === true };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function parseCompletion(body = {}) {
|
|
104
|
+
if (Array.isArray(body.prompt)) {
|
|
105
|
+
for (const prompt of body.prompt) assertTextContent(prompt, 'prompt');
|
|
106
|
+
} else {
|
|
107
|
+
assertTextContent(body.prompt, 'prompt');
|
|
108
|
+
}
|
|
109
|
+
const prompt = Array.isArray(body.prompt) ? body.prompt.map(textOf).join('\n') : textOf(body.prompt);
|
|
110
|
+
if (!prompt) throw new CompatError('prompt must contain text', 400, 'prompt');
|
|
111
|
+
return {
|
|
112
|
+
...resolveTarget(body.model, body.chatpanel),
|
|
113
|
+
system: '',
|
|
114
|
+
messages: [{ role: 'user', content: prompt }],
|
|
115
|
+
stream: body.stream === true,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function parseResponse(body = {}) {
|
|
120
|
+
if (body.tools?.length) throw new CompatError('Responses API tool calling is not supported by the bridge adapter', 400, 'tools');
|
|
121
|
+
let input = body.input;
|
|
122
|
+
if (typeof input === 'string') input = [{ role: 'user', content: input }];
|
|
123
|
+
if (!Array.isArray(input) || !input.length) throw new CompatError('input must be a string or non-empty array', 400, 'input');
|
|
124
|
+
assertTextContent(body.instructions, 'instructions');
|
|
125
|
+
for (const item of input) {
|
|
126
|
+
if (!['system', 'developer', 'user', 'assistant'].includes(item?.role)) {
|
|
127
|
+
throw new CompatError('input currently supports only text message items', 400, 'input');
|
|
128
|
+
}
|
|
129
|
+
assertTextContent(item.content, 'input');
|
|
130
|
+
}
|
|
131
|
+
const systemParts = [textOf(body.instructions)];
|
|
132
|
+
for (const item of input) {
|
|
133
|
+
if (item?.role === 'system' || item?.role === 'developer') systemParts.push(textOf(item.content));
|
|
134
|
+
}
|
|
135
|
+
const messages = normalizeMessages(input.filter((item) => item?.role !== 'system' && item?.role !== 'developer'));
|
|
136
|
+
if (!messages.length) throw new CompatError('input must contain user or assistant text', 400, 'input');
|
|
137
|
+
return {
|
|
138
|
+
...resolveTarget(body.model, body.chatpanel),
|
|
139
|
+
system: systemParts.filter(Boolean).join('\n\n'),
|
|
140
|
+
messages,
|
|
141
|
+
stream: body.stream === true,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function parseAnthropicMessage(body = {}) {
|
|
146
|
+
if (!Array.isArray(body.messages) || !body.messages.length) {
|
|
147
|
+
throw new CompatError('messages must be a non-empty array', 400, 'messages');
|
|
148
|
+
}
|
|
149
|
+
if (body.tools?.length) throw new CompatError('Anthropic tool use is not supported by the bridge adapter', 400, 'tools');
|
|
150
|
+
assertTextContent(body.system, 'system');
|
|
151
|
+
for (const message of body.messages) {
|
|
152
|
+
if (!['user', 'assistant'].includes(message?.role)) {
|
|
153
|
+
throw new CompatError(`Unsupported message role "${message?.role || ''}"`, 400, 'messages');
|
|
154
|
+
}
|
|
155
|
+
assertTextContent(message.content, 'messages');
|
|
156
|
+
}
|
|
157
|
+
const messages = normalizeMessages(body.messages);
|
|
158
|
+
if (!messages.length) throw new CompatError('messages must contain text', 400, 'messages');
|
|
159
|
+
return {
|
|
160
|
+
...resolveTarget(body.model, body.chatpanel),
|
|
161
|
+
system: textOf(body.system),
|
|
162
|
+
messages,
|
|
163
|
+
stream: body.stream === true,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function openAIError(error) {
|
|
168
|
+
return {
|
|
169
|
+
error: {
|
|
170
|
+
message: error?.message || String(error),
|
|
171
|
+
type: error instanceof CompatError ? 'invalid_request_error' : 'api_error',
|
|
172
|
+
param: error?.param || null,
|
|
173
|
+
code: null,
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function anthropicError(error) {
|
|
179
|
+
return {
|
|
180
|
+
type: 'error',
|
|
181
|
+
error: {
|
|
182
|
+
type: error instanceof CompatError ? 'invalid_request_error' : 'api_error',
|
|
183
|
+
message: error?.message || String(error),
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const zeroUsage = () => ({ prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 });
|
|
189
|
+
const responseUsage = () => ({ input_tokens: 0, input_tokens_details: { cached_tokens: 0 }, output_tokens: 0, output_tokens_details: { reasoning_tokens: 0 }, total_tokens: 0 });
|
|
190
|
+
|
|
191
|
+
export function createChatCompletion(model, text, { id = `chatcmpl-${randomUUID()}`, created = Math.floor(Date.now() / 1000) } = {}) {
|
|
192
|
+
return {
|
|
193
|
+
id, object: 'chat.completion', created, model,
|
|
194
|
+
choices: [{ index: 0, message: { role: 'assistant', content: text, refusal: null }, logprobs: null, finish_reason: 'stop' }],
|
|
195
|
+
usage: zeroUsage(),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function createCompletion(model, text, { id = `cmpl-${randomUUID()}`, created = Math.floor(Date.now() / 1000) } = {}) {
|
|
200
|
+
return {
|
|
201
|
+
id, object: 'text_completion', created, model,
|
|
202
|
+
choices: [{ text, index: 0, logprobs: null, finish_reason: 'stop' }],
|
|
203
|
+
usage: zeroUsage(),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function completionStream(model, onWrite, { id = `cmpl-${randomUUID()}`, created = Math.floor(Date.now() / 1000) } = {}) {
|
|
208
|
+
const chunk = (text, finish_reason = null) => ({
|
|
209
|
+
id, object: 'text_completion', created, model,
|
|
210
|
+
choices: [{ text, index: 0, logprobs: null, finish_reason }],
|
|
211
|
+
});
|
|
212
|
+
return {
|
|
213
|
+
delta(text) { if (text) onWrite(chunk(text)); },
|
|
214
|
+
done() { onWrite(chunk('', 'stop')); },
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function chatCompletionStream(model, onWrite, { id = `chatcmpl-${randomUUID()}`, created = Math.floor(Date.now() / 1000) } = {}) {
|
|
219
|
+
const chunk = (delta, finish_reason = null) => ({
|
|
220
|
+
id, object: 'chat.completion.chunk', created, model,
|
|
221
|
+
choices: [{ index: 0, delta, logprobs: null, finish_reason }],
|
|
222
|
+
});
|
|
223
|
+
let started = false;
|
|
224
|
+
return {
|
|
225
|
+
delta(text) {
|
|
226
|
+
if (!started) { onWrite(chunk({ role: 'assistant', content: '' })); started = true; }
|
|
227
|
+
if (text) onWrite(chunk({ content: text }));
|
|
228
|
+
},
|
|
229
|
+
done() {
|
|
230
|
+
if (!started) onWrite(chunk({ role: 'assistant', content: '' }));
|
|
231
|
+
onWrite(chunk({}, 'stop'));
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function responseObject(model, text, { id, created, status = 'completed' }) {
|
|
237
|
+
const itemId = `msg_${id.slice(-24)}`;
|
|
238
|
+
return {
|
|
239
|
+
id, object: 'response', created_at: created, status, error: null, incomplete_details: null,
|
|
240
|
+
instructions: null, max_output_tokens: null, model,
|
|
241
|
+
output: status === 'completed' ? [{ id: itemId, type: 'message', status: 'completed', role: 'assistant', content: [{ type: 'output_text', text, annotations: [], logprobs: [] }] }] : [],
|
|
242
|
+
parallel_tool_calls: false, previous_response_id: null, reasoning: { effort: null, summary: null }, store: false,
|
|
243
|
+
temperature: null, text: { format: { type: 'text' } }, tool_choice: 'none', tools: [], top_p: null,
|
|
244
|
+
truncation: 'disabled', usage: status === 'completed' ? responseUsage() : null, user: null, metadata: {},
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function createResponse(model, text, { id = `resp_${randomUUID()}`, created = Math.floor(Date.now() / 1000) } = {}) {
|
|
249
|
+
return responseObject(model, text, { id, created });
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function responseStream(model, onWrite, { id = `resp_${randomUUID()}`, created = Math.floor(Date.now() / 1000) } = {}) {
|
|
253
|
+
const itemId = `msg_${id.slice(-24)}`;
|
|
254
|
+
let sequence = 0;
|
|
255
|
+
let text = '';
|
|
256
|
+
onWrite({ type: 'response.created', sequence_number: sequence++, response: responseObject(model, '', { id, created, status: 'in_progress' }) });
|
|
257
|
+
onWrite({ type: 'response.in_progress', sequence_number: sequence++, response: responseObject(model, '', { id, created, status: 'in_progress' }) });
|
|
258
|
+
onWrite({ type: 'response.output_item.added', sequence_number: sequence++, output_index: 0, item: { id: itemId, type: 'message', status: 'in_progress', role: 'assistant', content: [] } });
|
|
259
|
+
onWrite({ type: 'response.content_part.added', sequence_number: sequence++, item_id: itemId, output_index: 0, content_index: 0, part: { type: 'output_text', text: '', annotations: [], logprobs: [] } });
|
|
260
|
+
return {
|
|
261
|
+
delta(value) {
|
|
262
|
+
if (!value) return;
|
|
263
|
+
text += value;
|
|
264
|
+
onWrite({ type: 'response.output_text.delta', sequence_number: sequence++, item_id: itemId, output_index: 0, content_index: 0, delta: value, logprobs: [] });
|
|
265
|
+
},
|
|
266
|
+
done() {
|
|
267
|
+
onWrite({ type: 'response.output_text.done', sequence_number: sequence++, item_id: itemId, output_index: 0, content_index: 0, text, logprobs: [] });
|
|
268
|
+
const part = { type: 'output_text', text, annotations: [], logprobs: [] };
|
|
269
|
+
onWrite({ type: 'response.content_part.done', sequence_number: sequence++, item_id: itemId, output_index: 0, content_index: 0, part });
|
|
270
|
+
const item = { id: itemId, type: 'message', status: 'completed', role: 'assistant', content: [part] };
|
|
271
|
+
onWrite({ type: 'response.output_item.done', sequence_number: sequence++, output_index: 0, item });
|
|
272
|
+
onWrite({ type: 'response.completed', sequence_number: sequence++, response: responseObject(model, text, { id, created }) });
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function createAnthropicMessage(model, text, { id = `msg_${randomUUID()}` } = {}) {
|
|
278
|
+
return { id, type: 'message', role: 'assistant', model, content: [{ type: 'text', text }], stop_reason: 'end_turn', stop_sequence: null, usage: { input_tokens: 0, output_tokens: 0 } };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function anthropicStream(model, onWrite, { id = `msg_${randomUUID()}` } = {}) {
|
|
282
|
+
onWrite('message_start', { type: 'message_start', message: { id, type: 'message', role: 'assistant', model, content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 0, output_tokens: 0 } } });
|
|
283
|
+
onWrite('content_block_start', { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } });
|
|
284
|
+
return {
|
|
285
|
+
delta(text) { if (text) onWrite('content_block_delta', { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text } }); },
|
|
286
|
+
done() {
|
|
287
|
+
onWrite('content_block_stop', { type: 'content_block_stop', index: 0 });
|
|
288
|
+
onWrite('message_delta', { type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: 0 } });
|
|
289
|
+
onWrite('message_stop', { type: 'message_stop' });
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// What an agent can already reach, read from its own configuration.
|
|
2
|
+
//
|
|
3
|
+
// A CLI agent brings connectors ChatPanel cannot see: a Slack MCP, a Jira MCP, a filesystem.
|
|
4
|
+
// ChatPanel relays its own tools to that agent and says a great deal about them — all of it
|
|
5
|
+
// restrictive, because each line was written to stop one substitution — so an agent asked
|
|
6
|
+
// about an internal thread read the page, saw the reference, and told the user to go and look
|
|
7
|
+
// it up, while holding a connector that reaches it.
|
|
8
|
+
//
|
|
9
|
+
// Knowing the names turns a guess into a fact. It lets the harness say "you have slack
|
|
10
|
+
// connected — use it" instead of listing connectors the agent may not have, and lets routing
|
|
11
|
+
// treat "can reach Slack" as a capability rather than a hope.
|
|
12
|
+
//
|
|
13
|
+
// NAMES ONLY, AND NEVER THE CREDENTIALS. A server's name is what the agent already knows and
|
|
14
|
+
// what a prompt needs; its URL, argv and env are what a leak would be made of. This reads
|
|
15
|
+
// config files that belong to the user's own agents and returns a list of strings.
|
|
16
|
+
//
|
|
17
|
+
// CONFIGURED, NOT PROVEN. A server listed here may still fail to start. That is honest and
|
|
18
|
+
// useful — it is exactly what the agent itself will try — and probing for real would cost a
|
|
19
|
+
// process spawn per agent on every health poll.
|
|
20
|
+
|
|
21
|
+
import { readFile } from 'node:fs/promises';
|
|
22
|
+
import os from 'node:os';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
|
|
25
|
+
/** Read a file and parse it as JSON, or null. Missing and malformed are the same answer. */
|
|
26
|
+
async function readJson(file) {
|
|
27
|
+
try { return JSON.parse(await readFile(file, 'utf8')); } catch { return null; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Every key of a `{ mcpServers: { name: {...} } }` block, wherever it appears. */
|
|
31
|
+
function fromMcpServers(obj) {
|
|
32
|
+
const m = obj && typeof obj === 'object' ? obj.mcpServers || obj.mcp_servers : null;
|
|
33
|
+
return m && typeof m === 'object' ? Object.keys(m) : [];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* `[mcp_servers.NAME]` table headers out of a TOML file.
|
|
38
|
+
*
|
|
39
|
+
* Deliberately a regex rather than a TOML parser: the bridge is zero-runtime-dependency by
|
|
40
|
+
* design, and the only thing wanted here is the set of table names. Anything this misses
|
|
41
|
+
* simply is not reported, which is the safe direction — a missing name costs a sentence in a
|
|
42
|
+
* prompt, an invented one sends an agent looking for a connector it does not have.
|
|
43
|
+
*/
|
|
44
|
+
function fromToml(text) {
|
|
45
|
+
const out = [];
|
|
46
|
+
const re = /^\s*\[\s*mcp_servers\s*\.\s*([A-Za-z0-9._-]+)\s*\]/gm;
|
|
47
|
+
for (const m of String(text || '').matchAll(re)) out.push(m[1]);
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function readToml(file) {
|
|
52
|
+
try { return fromToml(await readFile(file, 'utf8')); } catch { return []; }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const home = () => os.homedir();
|
|
56
|
+
const configHome = () => process.env.XDG_CONFIG_HOME || path.join(home(), '.config');
|
|
57
|
+
|
|
58
|
+
// Where each agent keeps the list. One entry per agent id in the /health registry; an agent
|
|
59
|
+
// with no entry simply reports nothing, which is what an unknown agent should do.
|
|
60
|
+
const SOURCES = {
|
|
61
|
+
claude: async () => {
|
|
62
|
+
const [main, project] = await Promise.all([
|
|
63
|
+
readJson(path.join(home(), '.claude.json')),
|
|
64
|
+
readJson(path.join(process.cwd(), '.mcp.json')),
|
|
65
|
+
]);
|
|
66
|
+
return [...fromMcpServers(main), ...fromMcpServers(project)];
|
|
67
|
+
},
|
|
68
|
+
codex: async () => readToml(path.join(process.env.CODEX_HOME || path.join(home(), '.codex'), 'config.toml')),
|
|
69
|
+
opencode: async () => {
|
|
70
|
+
for (const f of [
|
|
71
|
+
path.join(configHome(), 'opencode', 'opencode.json'),
|
|
72
|
+
path.join(home(), 'Library', 'Application Support', 'opencode', 'opencode.jsonc'),
|
|
73
|
+
]) {
|
|
74
|
+
const j = await readJson(f);
|
|
75
|
+
const names = [...fromMcpServers(j), ...(j && j.mcp && typeof j.mcp === 'object' ? Object.keys(j.mcp) : [])];
|
|
76
|
+
if (names.length) return names;
|
|
77
|
+
}
|
|
78
|
+
return [];
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The connector names an agent is configured with — deduped, sorted, and bounded.
|
|
84
|
+
*
|
|
85
|
+
* Bounded because this rides in a health response and then into a system prompt: a user with
|
|
86
|
+
* forty servers should cost a line, not a paragraph. Sorted so the value is stable across
|
|
87
|
+
* polls and a settings page does not reorder itself.
|
|
88
|
+
*/
|
|
89
|
+
export async function connectorsFor(agentId, { max = 24 } = {}) {
|
|
90
|
+
const read = SOURCES[agentId];
|
|
91
|
+
if (!read) return [];
|
|
92
|
+
try {
|
|
93
|
+
const names = await read();
|
|
94
|
+
return [...new Set(names.filter((n) => typeof n === 'string' && n && n.length <= 64))].sort().slice(0, max);
|
|
95
|
+
} catch {
|
|
96
|
+
// A config we cannot read is a config we say nothing about. Never a reason to fail a
|
|
97
|
+
// health check the extension needs in order to show the agent at all.
|
|
98
|
+
return [];
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -16,7 +16,7 @@ import os from 'node:os';
|
|
|
16
16
|
import path from 'node:path';
|
|
17
17
|
import { findAgentBin } from '../env.js';
|
|
18
18
|
import { buildCliPrompt } from './prompt.js';
|
|
19
|
-
import { killOnAbort } from '../proc.js';
|
|
19
|
+
import { killOnAbort, spawnGroupOpts } from '../proc.js';
|
|
20
20
|
import { pushExtraArgs, FORBIDDEN } from './args.js';
|
|
21
21
|
|
|
22
22
|
const IDLE_MS = Number(process.env.CHATPANEL_AGY_TIMEOUT_MS) || 180_000;
|
|
@@ -110,7 +110,7 @@ export async function chat({ messages, system, options, images }, emit, { signal
|
|
|
110
110
|
await new Promise((resolve, reject) => {
|
|
111
111
|
let child;
|
|
112
112
|
try {
|
|
113
|
-
child = spawn('agy', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env } });
|
|
113
|
+
child = spawn('agy', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env }, ...spawnGroupOpts });
|
|
114
114
|
} catch (e) {
|
|
115
115
|
cleanup();
|
|
116
116
|
return reject(new Error(`Failed to start agy: ${e.message}`));
|
package/src/engines/codex.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// the agent to point it at a real project.
|
|
16
16
|
|
|
17
17
|
import { spawn, spawnSync } from 'node:child_process';
|
|
18
|
-
import { killOnAbort } from '../proc.js';
|
|
18
|
+
import { killOnAbort, spawnGroupOpts } from '../proc.js';
|
|
19
19
|
import { readFile, unlink, writeFile } from 'node:fs/promises';
|
|
20
20
|
import { existsSync, mkdirSync, symlinkSync, readFileSync } from 'node:fs';
|
|
21
21
|
import os from 'node:os';
|
|
@@ -180,7 +180,7 @@ export async function chat({ messages, system, options, images }, emit, { signal
|
|
|
180
180
|
await new Promise((resolve, reject) => {
|
|
181
181
|
let child;
|
|
182
182
|
try {
|
|
183
|
-
child = spawn('codex', args, { cwd, stdio: ['pipe', 'pipe', 'pipe'], env });
|
|
183
|
+
child = spawn('codex', args, { cwd, stdio: ['pipe', 'pipe', 'pipe'], env, ...spawnGroupOpts });
|
|
184
184
|
} catch (e) {
|
|
185
185
|
cleanupImages();
|
|
186
186
|
return reject(new Error(`Failed to start codex: ${e.message}`));
|
package/src/env.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// by (1) asking your login shell for its PATH and (2) adding common bin dirs.
|
|
7
7
|
|
|
8
8
|
import os from 'node:os';
|
|
9
|
+
import { spawnGroupOpts } from './proc.js';
|
|
9
10
|
import path from 'node:path';
|
|
10
11
|
import { spawnSync } from 'node:child_process';
|
|
11
12
|
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
|
@@ -236,9 +237,15 @@ export function buildSpawnSpec(spec, args, cwd) {
|
|
|
236
237
|
if (wslCwd) pre.push('--cd', wslCwd); // else: run in WSL home
|
|
237
238
|
}
|
|
238
239
|
const argv = [...pre, '-e', 'bash', '-lic', `exec ${spec.command} "$@"`, 'chatpanel', ...args];
|
|
239
|
-
return ['wsl.exe', argv, { stdio: ['pipe', 'pipe', 'pipe'], env: process.env, windowsHide: true }];
|
|
240
|
+
return ['wsl.exe', argv, { stdio: ['pipe', 'pipe', 'pipe'], env: process.env, windowsHide: true, ...spawnGroupOpts }];
|
|
240
241
|
}
|
|
241
|
-
|
|
242
|
+
// Every spawned CLI leads its own process group, so Stop can signal the whole tree. An
|
|
243
|
+
// agent CLI is not one process — it runs shell commands and tools of its own — and killing
|
|
244
|
+
// only the pid we hold leaves those running after the user has stopped the turn.
|
|
245
|
+
const opts = {
|
|
246
|
+
cwd: cwd || os.homedir(), stdio: ['pipe', 'pipe', 'pipe'], env: process.env, windowsHide: true,
|
|
247
|
+
...spawnGroupOpts,
|
|
248
|
+
};
|
|
242
249
|
if (spec.kind === 'script') return [process.execPath, [spec.script, ...args], opts];
|
|
243
250
|
if (spec.kind === 'cmd') return ['cmd.exe', ['/d', '/s', '/c', spec.bin, ...args], opts];
|
|
244
251
|
return [spec.bin, args, opts]; // native
|
package/src/proc.js
CHANGED
|
@@ -1,18 +1,88 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
1
2
|
// Terminate a spawned CLI child when an AbortSignal fires. The extension's Stop
|
|
2
3
|
// button aborts the /chat request; server.js turns that disconnect into an abort on
|
|
3
4
|
// this signal. Without this, the agent CLI (codex / claude / agy / custom) keeps
|
|
4
5
|
// running to completion in the background after Stop — burning tokens and holding the
|
|
5
6
|
// session — and only the 3-minute idle timer would eventually reap it.
|
|
6
7
|
//
|
|
7
|
-
// SIGTERM first so the CLI can flush + exit cleanly
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
8
|
+
// SIGTERM first so the CLI can flush + exit cleanly, then SIGKILL after a short grace if
|
|
9
|
+
// it's still alive. Returns a detach() to drop the listener once the child exits normally.
|
|
10
|
+
//
|
|
11
|
+
// THE SIGNAL MUST REACH THE GRANDCHILDREN. An agent CLI is not one process: codex runs shell
|
|
12
|
+
// commands, claude runs tools, and each of those is a child of the child. `child.kill()`
|
|
13
|
+
// signals exactly one pid, so Stop killed the CLI and left its shell running — a user pressed
|
|
14
|
+
// Stop, watched the panel go quiet, and found the process still going.
|
|
15
|
+
//
|
|
16
|
+
// Signalling the process GROUP fixes that, and only works if the child leads a group of its
|
|
17
|
+
// own — which is what spawnGroupOpts is for. Without that spawn option the child sits in the
|
|
18
|
+
// bridge's own group, and a group kill would signal the bridge.
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Spawn options that make a child its own process-group leader, so the whole tree can be
|
|
22
|
+
* signalled together. No-op on Windows, which has no process groups in this sense — there
|
|
23
|
+
* the taskkill fallback in killTree covers it.
|
|
24
|
+
*/
|
|
25
|
+
export const spawnGroupOpts = process.platform === 'win32' ? {} : { detached: true };
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Every pid descended from `pid`, snapshotted from the process table.
|
|
29
|
+
*
|
|
30
|
+
* A process-group kill is not enough on its own. codex runs each shell step in its OWN
|
|
31
|
+
* process group (`sleep 90` came back with PGID == its own pid), so signalling the group we
|
|
32
|
+
* created deliberately misses it — and once its parent dies it reparents to init, where
|
|
33
|
+
* nothing connects it to the run that started it.
|
|
34
|
+
*
|
|
35
|
+
* So the tree is read BEFORE anything is signalled. Afterwards the links are gone.
|
|
36
|
+
*/
|
|
37
|
+
function descendantsOf(pid) {
|
|
38
|
+
if (!pid || process.platform === 'win32') return [];
|
|
39
|
+
let table = '';
|
|
40
|
+
try {
|
|
41
|
+
table = execFileSync('ps', ['-eo', 'pid=,ppid='], { encoding: 'utf8', timeout: 2000 });
|
|
42
|
+
} catch { return []; }
|
|
43
|
+
const kids = new Map();
|
|
44
|
+
for (const line of table.split('\n')) {
|
|
45
|
+
const [p, pp] = line.trim().split(/\s+/).map(Number);
|
|
46
|
+
if (!p || !pp) continue;
|
|
47
|
+
if (!kids.has(pp)) kids.set(pp, []);
|
|
48
|
+
kids.get(pp).push(p);
|
|
49
|
+
}
|
|
50
|
+
const out = [];
|
|
51
|
+
const walk = (root, depth = 0) => {
|
|
52
|
+
// A depth cap rather than a visited set: the table is a snapshot of a tree, and a cycle
|
|
53
|
+
// would mean the kernel lied. The cap is there so a malformed read cannot hang a Stop.
|
|
54
|
+
if (depth > 20) return;
|
|
55
|
+
for (const k of kids.get(root) || []) { out.push(k); walk(k, depth + 1); }
|
|
56
|
+
};
|
|
57
|
+
walk(pid);
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Signal a child and everything it spawned. Falls back to the single process. */
|
|
62
|
+
function killTree(child, sig, known = []) {
|
|
63
|
+
if (!child?.pid) return;
|
|
64
|
+
// Descendants first, while the parent is still alive to be traced through. Orphaned
|
|
65
|
+
// grandchildren are the ones the user cannot find or stop afterwards.
|
|
66
|
+
for (const pid of known.length ? known : descendantsOf(child.pid)) {
|
|
67
|
+
try { process.kill(pid, sig); } catch { /* already gone */ }
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
// Negative pid = the whole group. Only valid for a detached child; the catch covers a
|
|
71
|
+
// child spawned without it, which is still better killed alone than not at all.
|
|
72
|
+
process.kill(-child.pid, sig);
|
|
73
|
+
return;
|
|
74
|
+
} catch { /* not a group leader, or already gone */ }
|
|
75
|
+
try { child.kill(sig); } catch { /* already exited */ }
|
|
76
|
+
}
|
|
77
|
+
|
|
11
78
|
export function killOnAbort(child, signal, { graceMs = 1500 } = {}) {
|
|
12
79
|
if (!signal || !child) return () => {};
|
|
13
80
|
const onAbort = () => {
|
|
14
|
-
|
|
15
|
-
|
|
81
|
+
// Snapshot ONCE, up front: by the time the grace period expires the parent is gone and
|
|
82
|
+
// the tree cannot be walked, so the escalation would have nothing left to aim at.
|
|
83
|
+
const tree = descendantsOf(child.pid);
|
|
84
|
+
killTree(child, 'SIGTERM', tree);
|
|
85
|
+
const t = setTimeout(() => killTree(child, 'SIGKILL', tree), graceMs);
|
|
16
86
|
if (t.unref) t.unref(); // don't keep the event loop alive just for the grace timer
|
|
17
87
|
};
|
|
18
88
|
if (signal.aborted) { onAbort(); return () => {}; }
|
package/src/runs.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Every in-flight run, so Stop is an INSTRUCTION rather than an inference.
|
|
2
|
+
//
|
|
3
|
+
// Cancellation used to depend on Node noticing the client's socket close and firing
|
|
4
|
+
// `req.on('close')`. That is a signal about a socket, not about intent: it can arrive late,
|
|
5
|
+
// and on a request whose body was already consumed it does not reliably arrive at all — so
|
|
6
|
+
// a codex `shell:` step ran on for minutes after Stop with nothing left listening to it.
|
|
7
|
+
//
|
|
8
|
+
// A run registered here can be cancelled by name. The socket-close path stays as a safety
|
|
9
|
+
// net for a panel that is closed or crashes, but the button no longer depends on it.
|
|
10
|
+
const runs = new Map();
|
|
11
|
+
|
|
12
|
+
export function startRun(id) {
|
|
13
|
+
const ac = new AbortController();
|
|
14
|
+
const children = new Set();
|
|
15
|
+
const run = {
|
|
16
|
+
id,
|
|
17
|
+
signal: ac.signal,
|
|
18
|
+
// Children are tracked as well as signalled, because an agent that spawns sub-agents
|
|
19
|
+
// spawns processes this module never sees at spawn time. Whoever creates one registers
|
|
20
|
+
// it, and Stop reaches all of them.
|
|
21
|
+
track(child) { if (child?.pid) { children.add(child); child.once?.('exit', () => children.delete(child)); } },
|
|
22
|
+
cancel(reason = 'stopped') {
|
|
23
|
+
if (run.cancelled) return false;
|
|
24
|
+
run.cancelled = reason;
|
|
25
|
+
ac.abort();
|
|
26
|
+
return true;
|
|
27
|
+
},
|
|
28
|
+
cancelled: null,
|
|
29
|
+
children,
|
|
30
|
+
};
|
|
31
|
+
runs.set(id, run);
|
|
32
|
+
return run;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function endRun(id) { runs.delete(id); }
|
|
36
|
+
|
|
37
|
+
export function cancelRun(id, reason = 'stopped') {
|
|
38
|
+
const run = runs.get(id);
|
|
39
|
+
return run ? run.cancel(reason) : false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Used on shutdown: leaving a CLI running after the bridge exits is how orphans are made. */
|
|
43
|
+
export function cancelAll(reason = 'shutdown') {
|
|
44
|
+
let n = 0;
|
|
45
|
+
for (const run of runs.values()) if (run.cancel(reason)) n += 1;
|
|
46
|
+
return n;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const activeRuns = () => runs.size;
|
package/src/server.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// running on this machine (Claude Code, Codex and Antigravity, each via its CLI) to
|
|
4
4
|
// the ChatPanel Chrome extension. Zero runtime dependencies.
|
|
5
5
|
//
|
|
6
|
-
// GET /health → { ok, version, agents: [
|
|
6
|
+
// GET /health → { ok, version, agents: [{id,label,available,reason,connectors}], update }
|
|
7
7
|
// POST /update → self-update to the latest release (compiled binary installs)
|
|
8
8
|
// POST /chat → Server-Sent Events stream of { type, ... }:
|
|
9
9
|
// {type:'delta', text} incremental assistant text
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
// {type:'status'|'reasoning', text?}
|
|
12
12
|
// {type:'done', text?} (text only if not streamed)
|
|
13
13
|
// {type:'error', error}
|
|
14
|
+
// POST /v1/chat/completions, /v1/completions, /v1/responses
|
|
15
|
+
// → OpenAI-compatible text adapters for the local agents
|
|
16
|
+
// POST /v1/messages → Anthropic-compatible text adapter for the local agents
|
|
14
17
|
//
|
|
15
18
|
// Binds to 127.0.0.1 only. A request guard (see `guard()`) enforces a loopback
|
|
16
19
|
// Host (anti DNS-rebinding) and an allowlisted Origin; the command-spawning
|
|
@@ -27,6 +30,7 @@ import * as claude from './engines/claude.js';
|
|
|
27
30
|
import * as codex from './engines/codex.js';
|
|
28
31
|
import * as antigravity from './engines/antigravity.js';
|
|
29
32
|
import { pi, opencode, kiro, copilot, deepseek } from './engines/cli-agents.js';
|
|
33
|
+
import { connectorsFor } from './connectors.js';
|
|
30
34
|
import * as custom from './engines/custom.js';
|
|
31
35
|
import { installService, uninstallService, serviceStatus, restartService } from './service.js';
|
|
32
36
|
import { AGENT_CLIS, enrichPath, enrichAgentEnv, findAgentBin, resolveCommand } from './env.js';
|
|
@@ -34,11 +38,29 @@ import { stripHidden } from './sanitize.js';
|
|
|
34
38
|
import { checkForUpdate, selfUpdate } from './update.js';
|
|
35
39
|
import { callLocalMcp } from './mcp-local.js';
|
|
36
40
|
import { assertPublicHttpUrl, assertPublicWebUrl } from './ssrf.js';
|
|
41
|
+
import { startRun, endRun, cancelRun, cancelAll, activeRuns } from './runs.js';
|
|
42
|
+
import {
|
|
43
|
+
CompatError,
|
|
44
|
+
anthropicError,
|
|
45
|
+
anthropicStream,
|
|
46
|
+
chatCompletionStream,
|
|
47
|
+
completionStream,
|
|
48
|
+
createAnthropicMessage,
|
|
49
|
+
createChatCompletion,
|
|
50
|
+
createCompletion,
|
|
51
|
+
createResponse,
|
|
52
|
+
openAIError,
|
|
53
|
+
parseAnthropicMessage,
|
|
54
|
+
parseChatCompletion,
|
|
55
|
+
parseCompletion,
|
|
56
|
+
parseResponse,
|
|
57
|
+
responseStream,
|
|
58
|
+
} from './api-compat.js';
|
|
37
59
|
|
|
38
60
|
// Hardcoded (not read from package.json) so it survives Bun's single-file
|
|
39
61
|
// --compile, where package.json isn't on a readable FS. CI fails the publish if
|
|
40
62
|
// this drifts from package.json, so the two can't silently diverge.
|
|
41
|
-
const VERSION = '0.10.
|
|
63
|
+
const VERSION = '0.10.27';
|
|
42
64
|
const HOST = process.env.CHATPANEL_BRIDGE_HOST || '127.0.0.1';
|
|
43
65
|
const PORT = Number(process.env.CHATPANEL_BRIDGE_PORT) || 4319;
|
|
44
66
|
|
|
@@ -161,7 +183,7 @@ function cors(req, res) {
|
|
|
161
183
|
const allow = originAllowed(origin);
|
|
162
184
|
res.setHeader('Access-Control-Allow-Origin', allow ? origin || '*' : 'null');
|
|
163
185
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
164
|
-
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-ChatPanel-Token');
|
|
186
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Api-Key, Anthropic-Version, X-ChatPanel-Token');
|
|
165
187
|
res.setHeader('Vary', 'Origin');
|
|
166
188
|
}
|
|
167
189
|
|
|
@@ -217,7 +239,9 @@ function ensureToken() {
|
|
|
217
239
|
function tokenOk(req) {
|
|
218
240
|
if (!AUTH_TOKEN) return false;
|
|
219
241
|
const h = String(req.headers['authorization'] || '');
|
|
220
|
-
const provided = (h.startsWith('Bearer ')
|
|
242
|
+
const provided = (h.startsWith('Bearer ')
|
|
243
|
+
? h.slice(7)
|
|
244
|
+
: String(req.headers['x-api-key'] || req.headers['x-chatpanel-token'] || '')).trim();
|
|
221
245
|
if (!provided) return false;
|
|
222
246
|
const a = Buffer.from(provided);
|
|
223
247
|
const b = Buffer.from(AUTH_TOKEN);
|
|
@@ -230,6 +254,10 @@ function tokenOk(req) {
|
|
|
230
254
|
// local coding-agent CLIs connect to them with no Origin header by design.
|
|
231
255
|
const PRIVILEGED_POST = new Set([
|
|
232
256
|
'/chat',
|
|
257
|
+
'/v1/chat/completions',
|
|
258
|
+
'/v1/completions',
|
|
259
|
+
'/v1/responses',
|
|
260
|
+
'/v1/messages',
|
|
233
261
|
'/mcp-local',
|
|
234
262
|
'/mcp-remote',
|
|
235
263
|
'/fetch-title',
|
|
@@ -238,6 +266,10 @@ const PRIVILEGED_POST = new Set([
|
|
|
238
266
|
'/agent-check',
|
|
239
267
|
'/update',
|
|
240
268
|
'/tool-result',
|
|
269
|
+
// Cancelling someone else's run is a denial of service, small but real — and every other
|
|
270
|
+
// endpoint that touches a run is already guarded. An unauthenticated hole next to nine
|
|
271
|
+
// guarded neighbours is a hole regardless of how little it grants.
|
|
272
|
+
'/cancel',
|
|
241
273
|
]);
|
|
242
274
|
const PRIVILEGED_GET = new Set(['/debug']);
|
|
243
275
|
|
|
@@ -294,13 +326,43 @@ async function handleHealth(res) {
|
|
|
294
326
|
.filter(([, e]) => !e.hidden)
|
|
295
327
|
.map(async ([id, { engine, label }]) => {
|
|
296
328
|
const a = await engine.available().catch((e) => ({ ok: false, reason: String(e?.message || e) }));
|
|
297
|
-
|
|
329
|
+
// WHAT THIS AGENT CAN ALREADY REACH, so the client stops guessing. A CLI agent brings
|
|
330
|
+
// its own connectors — a Slack MCP, a Jira MCP — that the extension cannot see, and
|
|
331
|
+
// an agent that was never told it may use them answers "go and look it up yourself".
|
|
332
|
+
// NAMES ONLY: a server's name is what a prompt needs; its URL, argv and env are what
|
|
333
|
+
// a leak would be made of. Additive, so an older extension ignores it.
|
|
334
|
+
const connectors = await connectorsFor(id).catch(() => []);
|
|
335
|
+
return { id, label, available: a.ok, reason: a.reason, connectors };
|
|
298
336
|
}),
|
|
299
337
|
);
|
|
300
338
|
const update = await checkForUpdate(VERSION).catch(() => ({ current: VERSION, updateAvailable: false }));
|
|
301
339
|
json(res, 200, { ok: true, version: VERSION, agents, update });
|
|
302
340
|
}
|
|
303
341
|
|
|
342
|
+
async function compatibleModels() {
|
|
343
|
+
const rows = await Promise.all(
|
|
344
|
+
Object.entries(ENGINES)
|
|
345
|
+
.filter(([, entry]) => !entry.hidden)
|
|
346
|
+
.map(async ([id, entry]) => ({
|
|
347
|
+
id,
|
|
348
|
+
object: 'model',
|
|
349
|
+
created: 0,
|
|
350
|
+
owned_by: 'chatpanel',
|
|
351
|
+
available: !!(await entry.engine.available().catch(() => ({ ok: false }))).ok,
|
|
352
|
+
})),
|
|
353
|
+
);
|
|
354
|
+
return rows.filter((row) => row.available).map(({ available, ...row }) => row);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function handleCompatibleModels(res, modelId = '') {
|
|
358
|
+
const models = await compatibleModels();
|
|
359
|
+
if (modelId) {
|
|
360
|
+
const model = models.find((row) => row.id === modelId);
|
|
361
|
+
return model ? json(res, 200, model) : json(res, 404, openAIError(new CompatError(`Model "${modelId}" not found`, 404, 'model')));
|
|
362
|
+
}
|
|
363
|
+
return json(res, 200, { object: 'list', data: models });
|
|
364
|
+
}
|
|
365
|
+
|
|
304
366
|
// POST /update — self-update (compiled-binary installs). Swaps the binary, replies,
|
|
305
367
|
// then restarts the service into the new version. npm installs get instructions.
|
|
306
368
|
async function handleUpdate(res) {
|
|
@@ -341,10 +403,22 @@ async function handleChat(req, res) {
|
|
|
341
403
|
// finish in the background. Older engines ignore the signal (harmless); the spawn
|
|
342
404
|
// engines honor it via killOnAbort.
|
|
343
405
|
let closed = false;
|
|
344
|
-
|
|
345
|
-
|
|
406
|
+
// A run id the client can cancel BY NAME. Emitted first, before anything else, so Stop
|
|
407
|
+
// works from the first millisecond rather than from whenever the engine gets going.
|
|
408
|
+
const runId = `run_${Math.random().toString(36).slice(2, 10)}`;
|
|
409
|
+
const run = startRun(runId);
|
|
410
|
+
const ac = { signal: run.signal, abort: () => run.cancel('client') };
|
|
411
|
+
// BOTH close events. On a request whose body was already consumed, req 'close' does not
|
|
412
|
+
// reliably signal a client disconnect — res 'close' does. Keeping both means a panel that
|
|
413
|
+
// crashes or is closed still tears the CLI down, while the Stop button no longer depends
|
|
414
|
+
// on either of them.
|
|
415
|
+
const onGone = () => { closed = true; run.cancel('disconnected'); };
|
|
416
|
+
req.on('close', onGone);
|
|
417
|
+
res.on('close', onGone);
|
|
418
|
+
res.on('error', onGone);
|
|
346
419
|
|
|
347
420
|
const safeEmit = (obj) => { if (!closed) emit(obj); };
|
|
421
|
+
emit({ type: 'run', id: runId });
|
|
348
422
|
|
|
349
423
|
// Browser-tools relay: when the extension sends page-tool specs, host an MCP
|
|
350
424
|
// server for this turn and tell the engine to point the CLI at it.
|
|
@@ -374,11 +448,187 @@ async function handleChat(req, res) {
|
|
|
374
448
|
log('error', `${body.agent} chat failed: ${e?.message || e}`);
|
|
375
449
|
emit({ type: 'error', error: e?.message || String(e) });
|
|
376
450
|
} finally {
|
|
451
|
+
endRun(runId);
|
|
377
452
|
if (session) deleteSession(session.id);
|
|
378
453
|
if (!res.writableEnded) res.end();
|
|
379
454
|
}
|
|
380
455
|
}
|
|
381
456
|
|
|
457
|
+
async function runCompatibleAgent(config, onDelta, res) {
|
|
458
|
+
const target = ENGINES[config.agent];
|
|
459
|
+
if (!target || target.hidden) throw new CompatError(`Unknown ChatPanel agent "${config.agent}"`, 400, 'model');
|
|
460
|
+
const availability = await target.engine.available().catch((e) => ({ ok: false, reason: e?.message || String(e) }));
|
|
461
|
+
if (!availability.ok) throw new CompatError(availability.reason || `${config.agent} is unavailable`, 503, 'model');
|
|
462
|
+
|
|
463
|
+
const runId = `run_${Math.random().toString(36).slice(2, 10)}`;
|
|
464
|
+
const run = startRun(runId);
|
|
465
|
+
const onGone = () => run.cancel('disconnected');
|
|
466
|
+
res.on('close', onGone);
|
|
467
|
+
let output = '';
|
|
468
|
+
try {
|
|
469
|
+
await target.engine.chat(
|
|
470
|
+
{ messages: config.messages, system: config.system, options: config.options, images: [] },
|
|
471
|
+
(event) => {
|
|
472
|
+
if (event?.type === 'delta' && event.text) {
|
|
473
|
+
output += event.text;
|
|
474
|
+
onDelta(event.text);
|
|
475
|
+
} else if (event?.type === 'done' && event.text && !output) {
|
|
476
|
+
output = event.text;
|
|
477
|
+
onDelta(event.text);
|
|
478
|
+
}
|
|
479
|
+
},
|
|
480
|
+
{ signal: run.signal },
|
|
481
|
+
);
|
|
482
|
+
return output;
|
|
483
|
+
} finally {
|
|
484
|
+
res.off('close', onGone);
|
|
485
|
+
endRun(runId);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function beginSse(res) {
|
|
490
|
+
res.writeHead(200, {
|
|
491
|
+
'Content-Type': 'text/event-stream',
|
|
492
|
+
'Cache-Control': 'no-cache',
|
|
493
|
+
Connection: 'keep-alive',
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function writeData(res, value) {
|
|
498
|
+
if (!res.writableEnded) res.write(`data: ${typeof value === 'string' ? value : JSON.stringify(value)}\n\n`);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function writeNamedEvent(res, name, value) {
|
|
502
|
+
if (!res.writableEnded) res.write(`event: ${name}\ndata: ${JSON.stringify(value)}\n\n`);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
async function compatibleBody(req, res, parser, errorShape) {
|
|
506
|
+
try {
|
|
507
|
+
return parser(await readBody(req));
|
|
508
|
+
} catch (error) {
|
|
509
|
+
json(res, error?.status || 400, errorShape(error?.message?.startsWith('Unexpected') ? new CompatError(`Bad JSON: ${error.message}`) : error));
|
|
510
|
+
return null;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async function handleOpenAIChatCompletions(req, res) {
|
|
515
|
+
const config = await compatibleBody(req, res, parseChatCompletion, openAIError);
|
|
516
|
+
if (!config) return;
|
|
517
|
+
if (!config.stream) {
|
|
518
|
+
try {
|
|
519
|
+
const text = await runCompatibleAgent(config, () => {}, res);
|
|
520
|
+
return json(res, 200, createChatCompletion(config.requestedModel, text));
|
|
521
|
+
} catch (error) {
|
|
522
|
+
if (!res.writableEnded) return json(res, error?.status || 500, openAIError(error));
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
beginSse(res);
|
|
528
|
+
const stream = chatCompletionStream(config.requestedModel, (event) => writeData(res, event));
|
|
529
|
+
try {
|
|
530
|
+
await runCompatibleAgent(config, (text) => stream.delta(text), res);
|
|
531
|
+
stream.done();
|
|
532
|
+
} catch (error) {
|
|
533
|
+
writeData(res, openAIError(error));
|
|
534
|
+
}
|
|
535
|
+
writeData(res, '[DONE]');
|
|
536
|
+
res.end();
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
async function handleOpenAICompletions(req, res) {
|
|
540
|
+
const config = await compatibleBody(req, res, parseCompletion, openAIError);
|
|
541
|
+
if (!config) return;
|
|
542
|
+
if (!config.stream) {
|
|
543
|
+
try {
|
|
544
|
+
const text = await runCompatibleAgent(config, () => {}, res);
|
|
545
|
+
return json(res, 200, createCompletion(config.requestedModel, text));
|
|
546
|
+
} catch (error) {
|
|
547
|
+
if (!res.writableEnded) return json(res, error?.status || 500, openAIError(error));
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
beginSse(res);
|
|
553
|
+
const stream = completionStream(config.requestedModel, (event) => writeData(res, event));
|
|
554
|
+
try {
|
|
555
|
+
await runCompatibleAgent(config, (text) => stream.delta(text), res);
|
|
556
|
+
stream.done();
|
|
557
|
+
} catch (error) {
|
|
558
|
+
writeData(res, openAIError(error));
|
|
559
|
+
}
|
|
560
|
+
writeData(res, '[DONE]');
|
|
561
|
+
res.end();
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
async function handleOpenAIResponses(req, res) {
|
|
565
|
+
const config = await compatibleBody(req, res, parseResponse, openAIError);
|
|
566
|
+
if (!config) return;
|
|
567
|
+
if (!config.stream) {
|
|
568
|
+
try {
|
|
569
|
+
const text = await runCompatibleAgent(config, () => {}, res);
|
|
570
|
+
return json(res, 200, createResponse(config.requestedModel, text));
|
|
571
|
+
} catch (error) {
|
|
572
|
+
if (!res.writableEnded) return json(res, error?.status || 500, openAIError(error));
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
beginSse(res);
|
|
578
|
+
const stream = responseStream(config.requestedModel, (event) => writeNamedEvent(res, event.type, event));
|
|
579
|
+
try {
|
|
580
|
+
await runCompatibleAgent(config, (text) => stream.delta(text), res);
|
|
581
|
+
stream.done();
|
|
582
|
+
} catch (error) {
|
|
583
|
+
const event = { type: 'error', sequence_number: 0, ...openAIError(error) };
|
|
584
|
+
writeNamedEvent(res, 'error', event);
|
|
585
|
+
}
|
|
586
|
+
res.end();
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
async function handleAnthropicMessages(req, res) {
|
|
590
|
+
const config = await compatibleBody(req, res, parseAnthropicMessage, anthropicError);
|
|
591
|
+
if (!config) return;
|
|
592
|
+
if (!config.stream) {
|
|
593
|
+
try {
|
|
594
|
+
const text = await runCompatibleAgent(config, () => {}, res);
|
|
595
|
+
return json(res, 200, createAnthropicMessage(config.requestedModel, text));
|
|
596
|
+
} catch (error) {
|
|
597
|
+
if (!res.writableEnded) return json(res, error?.status || 500, anthropicError(error));
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
beginSse(res);
|
|
603
|
+
const stream = anthropicStream(config.requestedModel, (name, event) => writeNamedEvent(res, name, event));
|
|
604
|
+
try {
|
|
605
|
+
await runCompatibleAgent(config, (text) => stream.delta(text), res);
|
|
606
|
+
stream.done();
|
|
607
|
+
} catch (error) {
|
|
608
|
+
writeNamedEvent(res, 'error', anthropicError(error));
|
|
609
|
+
}
|
|
610
|
+
res.end();
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* POST /cancel { id } — stop a run by name.
|
|
615
|
+
*
|
|
616
|
+
* Stop is now an instruction, not something inferred from a socket. Answers 200 whether or
|
|
617
|
+
* not the run was still live: 'already finished' and 'cancelled' are the same outcome to the
|
|
618
|
+
* caller, and returning 404 would make a harmless race look like a failure.
|
|
619
|
+
*/
|
|
620
|
+
async function handleCancel(req, res) {
|
|
621
|
+
// readBody already PARSES. Wrapping it in JSON.parse threw on every call, so the id was
|
|
622
|
+
// always empty and Stop silently cancelled nothing — the unit tests covered the registry
|
|
623
|
+
// and not the handler that feeds it, which is exactly where this hid.
|
|
624
|
+
let body = {};
|
|
625
|
+
try { body = (await readBody(req)) || {}; } catch { /* an empty body cancels nothing */ }
|
|
626
|
+
const id = String(body.id || '').trim();
|
|
627
|
+
const cancelled = id ? cancelRun(id, 'stopped') : false;
|
|
628
|
+
if (cancelled) log('info', `cancel: ${id} stopped by client`);
|
|
629
|
+
return json(res, 200, { ok: true, cancelled });
|
|
630
|
+
}
|
|
631
|
+
|
|
382
632
|
// POST /mcp/<session> (per-run, bridge-injected) OR POST /mcp (stable: routes to
|
|
383
633
|
// the active chat — for CLIs configured once, e.g. `opencode mcp add … …/mcp`).
|
|
384
634
|
// JSON-RPC; tools/call relays to the extension and waits for /tool-result.
|
|
@@ -728,6 +978,10 @@ const server = createServer(async (req, res) => {
|
|
|
728
978
|
if (blocked) return json(res, 403, { error: blocked });
|
|
729
979
|
try {
|
|
730
980
|
if (req.method === 'GET' && url.pathname === '/health') return handleHealth(res);
|
|
981
|
+
if (req.method === 'GET' && url.pathname === '/v1/models') return handleCompatibleModels(res);
|
|
982
|
+
if (req.method === 'GET' && url.pathname.startsWith('/v1/models/')) {
|
|
983
|
+
return handleCompatibleModels(res, decodeURIComponent(url.pathname.slice('/v1/models/'.length)));
|
|
984
|
+
}
|
|
731
985
|
if (req.method === 'GET' && url.pathname === '/debug') {
|
|
732
986
|
// L6: by default expose only version + agent AVAILABILITY (a boolean) — enough
|
|
733
987
|
// to diagnose "is codex installed?". The full home dir, $PATH, and resolved
|
|
@@ -742,6 +996,10 @@ const server = createServer(async (req, res) => {
|
|
|
742
996
|
});
|
|
743
997
|
}
|
|
744
998
|
if (req.method === 'POST' && url.pathname === '/chat') return handleChat(req, res);
|
|
999
|
+
if (req.method === 'POST' && url.pathname === '/v1/chat/completions') return handleOpenAIChatCompletions(req, res);
|
|
1000
|
+
if (req.method === 'POST' && url.pathname === '/v1/completions') return handleOpenAICompletions(req, res);
|
|
1001
|
+
if (req.method === 'POST' && url.pathname === '/v1/responses') return handleOpenAIResponses(req, res);
|
|
1002
|
+
if (req.method === 'POST' && url.pathname === '/v1/messages') return handleAnthropicMessages(req, res);
|
|
745
1003
|
// Stable endpoint: routes to the active chat. For CLIs configured once with a
|
|
746
1004
|
// fixed URL (e.g. `opencode mcp add chatpanel --url http://127.0.0.1:4319/mcp`).
|
|
747
1005
|
if (url.pathname === '/mcp') {
|
|
@@ -754,6 +1012,7 @@ const server = createServer(async (req, res) => {
|
|
|
754
1012
|
if (req.method === 'GET') { res.writeHead(405); return res.end(); } // no server-initiated stream
|
|
755
1013
|
if (req.method === 'DELETE') { deleteSession(sid); res.writeHead(204); return res.end(); }
|
|
756
1014
|
}
|
|
1015
|
+
if (req.method === 'POST' && url.pathname === '/cancel') return handleCancel(req, res);
|
|
757
1016
|
if (req.method === 'POST' && url.pathname === '/tool-result') return handleToolResult(req, res);
|
|
758
1017
|
if (req.method === 'POST' && url.pathname === '/mcp-local') return handleMcpLocal(req, res);
|
|
759
1018
|
if (req.method === 'POST' && url.pathname === '/mcp-remote') return handleMcpRemote(req, res);
|
|
@@ -841,6 +1100,18 @@ function startServer() {
|
|
|
841
1100
|
log('error', `bridge server error: ${e?.message || e}`);
|
|
842
1101
|
process.exit(1);
|
|
843
1102
|
});
|
|
1103
|
+
// Leaving a CLI running after the bridge exits is how orphans are made — and the user has
|
|
1104
|
+
// no way to find or stop them, because the thing that spawned them is gone.
|
|
1105
|
+
for (const sig of ['SIGINT', 'SIGTERM']) {
|
|
1106
|
+
process.on(sig, () => {
|
|
1107
|
+
const n = cancelAll('shutdown');
|
|
1108
|
+
if (n) log('info', `shutdown: stopped ${n} running agent${n === 1 ? '' : 's'}`);
|
|
1109
|
+
// Give SIGTERM a moment to land before the process goes; killTree escalates on its own.
|
|
1110
|
+
setTimeout(() => process.exit(0), n ? 300 : 0).unref?.();
|
|
1111
|
+
if (!n) process.exit(0);
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
|
|
844
1115
|
server.listen(PORT, HOST, async () => {
|
|
845
1116
|
log('info', `listening on http://${HOST}:${PORT}`);
|
|
846
1117
|
// M7: a non-loopback bind disables the anti-DNS-rebinding Host check (hostAllowed
|