@spinabot/brigade 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/LICENSE +21 -0
- package/README.md +154 -0
- package/SECURITY.md +208 -0
- package/assets/brigade-wordmark-on-black.png +0 -0
- package/assets/brigade-wordmark.png +0 -0
- package/brigade.mjs +96 -0
- package/dist/cli/chat-cmd.js +120 -0
- package/dist/cli/config-cmd.js +132 -0
- package/dist/cli/connect-cmd.js +447 -0
- package/dist/cli/doctor-cmd.js +317 -0
- package/dist/cli/gateway-cmd.js +92 -0
- package/dist/cli.js +287 -0
- package/dist/core/agent.js +1123 -0
- package/dist/core/config.js +80 -0
- package/dist/core/console-stream.js +188 -0
- package/dist/core/error-classifier.js +354 -0
- package/dist/core/event-logger.js +122 -0
- package/dist/core/model-caps.js +185 -0
- package/dist/core/provider-payload-mutators.js +517 -0
- package/dist/core/provider-quirks.js +285 -0
- package/dist/core/server.js +459 -0
- package/dist/core/smart-compaction.js +209 -0
- package/dist/core/system-prompt-defaults.js +88 -0
- package/dist/core/system-prompt-guidance.js +269 -0
- package/dist/core/system-prompt.js +884 -0
- package/dist/index.js +30 -0
- package/dist/integrations/ollama.js +140 -0
- package/dist/protocol.js +49 -0
- package/dist/providers/catalog.js +100 -0
- package/dist/providers/validate-key.js +197 -0
- package/dist/tui/client.js +263 -0
- package/dist/ui/brand-frames-cli.js +20 -0
- package/dist/ui/brand-frames.js +36 -0
- package/dist/ui/brand.js +402 -0
- package/dist/ui/chat.js +929 -0
- package/dist/ui/onboarding.js +400 -0
- package/dist/ui/theme.js +51 -0
- package/package.json +92 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brigade gateway client.
|
|
3
|
+
*
|
|
4
|
+
* Connects to the server's WebSocket endpoint, sends typed requests, and
|
|
5
|
+
* delivers events to subscribers. Survives transient disconnects via
|
|
6
|
+
* exponential-backoff reconnect; detects dead servers via tick timeout.
|
|
7
|
+
*
|
|
8
|
+
* Used by: the TUI (`src/index.ts` boots this and hands it to `chat.ts`).
|
|
9
|
+
* A future web/mobile client implements the same shape against the same
|
|
10
|
+
* wire protocol — same code on the wire, different transport on the page.
|
|
11
|
+
*
|
|
12
|
+
* Resilience features:
|
|
13
|
+
* - Exponential reconnect with jitter and a cap (1s → 30s)
|
|
14
|
+
* - Tick watchdog: if no frame received in 2× TICK_INTERVAL_MS, close +
|
|
15
|
+
* reconnect (catches half-open TCP sockets)
|
|
16
|
+
* - Pending-request timeout (default 60s) so callers never hang forever
|
|
17
|
+
* - One-shot connect: caller awaits `client.ready` before sending requests
|
|
18
|
+
*/
|
|
19
|
+
import { EventEmitter } from "node:events";
|
|
20
|
+
import WebSocket from "ws";
|
|
21
|
+
import { DEFAULT_PORT, isFrame, TICK_INTERVAL_MS, } from "../protocol.js";
|
|
22
|
+
/**
|
|
23
|
+
* Brigade gateway client. Construct → `await client.connect()` → use.
|
|
24
|
+
*
|
|
25
|
+
* Two surfaces:
|
|
26
|
+
* - `request(method, params)` — typed request/response (Promise)
|
|
27
|
+
* - `on(event, handler)` — typed event subscription
|
|
28
|
+
*
|
|
29
|
+
* The `EventEmitter` parent gives us `on` / `off` for free; we wrap with
|
|
30
|
+
* typed signatures so callers get autocomplete for event names + payload.
|
|
31
|
+
*/
|
|
32
|
+
export class BrigadeClient extends EventEmitter {
|
|
33
|
+
ws;
|
|
34
|
+
url;
|
|
35
|
+
requestTimeoutMs;
|
|
36
|
+
/** True once a connection is OPEN. False after close until reconnect. */
|
|
37
|
+
connected = false;
|
|
38
|
+
/** Caller called close(); don't auto-reconnect. */
|
|
39
|
+
closed = false;
|
|
40
|
+
/** id → resolver for pending requests. `timer` is undefined when the
|
|
41
|
+
* caller passed `timeoutMs: 0` (or Infinity) to disable the auto-reject
|
|
42
|
+
* timer — long-running requests like `prompt` rely on this. */
|
|
43
|
+
pending = new Map();
|
|
44
|
+
nextId = 1;
|
|
45
|
+
/** Last time we received any frame from the server. Tick watchdog reads this. */
|
|
46
|
+
lastFrameAt = 0;
|
|
47
|
+
tickWatchTimer;
|
|
48
|
+
/** Reconnect state. */
|
|
49
|
+
reconnectAttempt = 0;
|
|
50
|
+
reconnectTimer;
|
|
51
|
+
constructor(opts = {}) {
|
|
52
|
+
super();
|
|
53
|
+
this.url = opts.url ?? `ws://127.0.0.1:${DEFAULT_PORT}`;
|
|
54
|
+
this.requestTimeoutMs = opts.requestTimeoutMs ?? 60_000;
|
|
55
|
+
}
|
|
56
|
+
/** Open the connection. Resolves once the socket is OPEN. */
|
|
57
|
+
async connect() {
|
|
58
|
+
await this.openSocket();
|
|
59
|
+
this.startTickWatch();
|
|
60
|
+
}
|
|
61
|
+
/** Close the connection permanently. Cancels reconnect, rejects pending. */
|
|
62
|
+
close() {
|
|
63
|
+
this.closed = true;
|
|
64
|
+
this.stopTickWatch();
|
|
65
|
+
if (this.reconnectTimer)
|
|
66
|
+
clearTimeout(this.reconnectTimer);
|
|
67
|
+
this.reconnectTimer = undefined;
|
|
68
|
+
// Reject all pending requests so callers don't hang.
|
|
69
|
+
for (const [id, p] of this.pending) {
|
|
70
|
+
if (p.timer)
|
|
71
|
+
clearTimeout(p.timer);
|
|
72
|
+
p.reject(new Error("client closed"));
|
|
73
|
+
this.pending.delete(id);
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
this.ws?.close();
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
/* ignore */
|
|
80
|
+
}
|
|
81
|
+
this.ws = undefined;
|
|
82
|
+
this.connected = false;
|
|
83
|
+
}
|
|
84
|
+
/** True if the underlying socket is currently OPEN. */
|
|
85
|
+
get isConnected() {
|
|
86
|
+
return this.connected;
|
|
87
|
+
}
|
|
88
|
+
/* ─────────────────────────── public typed API ─────────────────────────── */
|
|
89
|
+
/**
|
|
90
|
+
* Send a typed request and await the typed response. Promise rejects
|
|
91
|
+
* if the server returns an error frame, the (per-request OR client-default)
|
|
92
|
+
* timeout elapses, or the socket closes before a response arrives.
|
|
93
|
+
*/
|
|
94
|
+
async request(method, params, options) {
|
|
95
|
+
if (!this.connected || !this.ws) {
|
|
96
|
+
throw new Error("client not connected");
|
|
97
|
+
}
|
|
98
|
+
const id = `r${this.nextId++}`;
|
|
99
|
+
const frame = { type: "req", id, method, params };
|
|
100
|
+
const ws = this.ws;
|
|
101
|
+
const effectiveTimeout = options?.timeoutMs !== undefined ? options.timeoutMs : this.requestTimeoutMs;
|
|
102
|
+
return new Promise((resolve, reject) => {
|
|
103
|
+
let timer;
|
|
104
|
+
if (effectiveTimeout > 0 && Number.isFinite(effectiveTimeout)) {
|
|
105
|
+
timer = setTimeout(() => {
|
|
106
|
+
this.pending.delete(id);
|
|
107
|
+
reject(new Error(`request timeout after ${effectiveTimeout}ms (${method})`));
|
|
108
|
+
}, effectiveTimeout);
|
|
109
|
+
}
|
|
110
|
+
this.pending.set(id, {
|
|
111
|
+
resolve: (payload) => resolve(payload),
|
|
112
|
+
reject,
|
|
113
|
+
timer, // optional — undefined when timeoutMs<=0 or non-finite
|
|
114
|
+
});
|
|
115
|
+
try {
|
|
116
|
+
ws.send(JSON.stringify(frame));
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
if (timer)
|
|
120
|
+
clearTimeout(timer);
|
|
121
|
+
this.pending.delete(id);
|
|
122
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
/** Type-aware event subscription. Returns `this` for chaining (matches EventEmitter). */
|
|
127
|
+
on(event, listener) {
|
|
128
|
+
return super.on(event, listener);
|
|
129
|
+
}
|
|
130
|
+
off(event, listener) {
|
|
131
|
+
return super.off(event, listener);
|
|
132
|
+
}
|
|
133
|
+
/* ─────────────────────────── socket lifecycle ─────────────────────────── */
|
|
134
|
+
openSocket() {
|
|
135
|
+
return new Promise((resolve, reject) => {
|
|
136
|
+
const ws = new WebSocket(this.url);
|
|
137
|
+
this.ws = ws;
|
|
138
|
+
let resolved = false;
|
|
139
|
+
ws.on("open", () => {
|
|
140
|
+
this.connected = true;
|
|
141
|
+
this.reconnectAttempt = 0;
|
|
142
|
+
this.lastFrameAt = Date.now();
|
|
143
|
+
resolved = true;
|
|
144
|
+
resolve();
|
|
145
|
+
});
|
|
146
|
+
ws.on("message", (data) => {
|
|
147
|
+
this.lastFrameAt = Date.now();
|
|
148
|
+
let frame;
|
|
149
|
+
try {
|
|
150
|
+
const parsed = JSON.parse(data.toString());
|
|
151
|
+
if (!isFrame(parsed))
|
|
152
|
+
return;
|
|
153
|
+
frame = parsed;
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
this.dispatchFrame(frame);
|
|
159
|
+
});
|
|
160
|
+
ws.on("close", () => {
|
|
161
|
+
this.connected = false;
|
|
162
|
+
this.ws = undefined;
|
|
163
|
+
// Reject every pending request — the server's session for this
|
|
164
|
+
// socket is gone, so any in-flight request will NEVER receive a
|
|
165
|
+
// response on this connection. Without this, requests with
|
|
166
|
+
// `timeoutMs: 0` (e.g. long-running prompts) orphan in the
|
|
167
|
+
// pending map and the awaiting caller hangs forever, even
|
|
168
|
+
// after a successful reconnect.
|
|
169
|
+
//
|
|
170
|
+
// We reject BEFORE scheduleReconnect so the caller sees the
|
|
171
|
+
// drop and can decide whether to retry. The reconnect itself
|
|
172
|
+
// is a transport-level recovery; pending request resumption
|
|
173
|
+
// is a higher-level concern (caller policy).
|
|
174
|
+
for (const [id, p] of this.pending) {
|
|
175
|
+
if (p.timer)
|
|
176
|
+
clearTimeout(p.timer);
|
|
177
|
+
p.reject(new Error("connection lost — request was in flight when socket closed"));
|
|
178
|
+
this.pending.delete(id);
|
|
179
|
+
}
|
|
180
|
+
if (!resolved)
|
|
181
|
+
reject(new Error("socket closed before open"));
|
|
182
|
+
if (!this.closed)
|
|
183
|
+
this.scheduleReconnect();
|
|
184
|
+
});
|
|
185
|
+
ws.on("error", (err) => {
|
|
186
|
+
if (!resolved) {
|
|
187
|
+
resolved = true;
|
|
188
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
scheduleReconnect() {
|
|
194
|
+
if (this.closed)
|
|
195
|
+
return;
|
|
196
|
+
this.reconnectAttempt++;
|
|
197
|
+
// Exponential backoff with jitter: 1s, 2s, 4s, ... cap at 30s.
|
|
198
|
+
const baseMs = Math.min(1000 * 2 ** (this.reconnectAttempt - 1), 30_000);
|
|
199
|
+
const jitter = Math.floor(Math.random() * 500);
|
|
200
|
+
const delay = baseMs + jitter;
|
|
201
|
+
this.reconnectTimer = setTimeout(async () => {
|
|
202
|
+
this.reconnectTimer = undefined;
|
|
203
|
+
try {
|
|
204
|
+
await this.openSocket();
|
|
205
|
+
this.emit("reconnected");
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
// openSocket rejected → its close handler will scheduleReconnect again
|
|
209
|
+
}
|
|
210
|
+
}, delay);
|
|
211
|
+
}
|
|
212
|
+
dispatchFrame(frame) {
|
|
213
|
+
if (frame.type === "res") {
|
|
214
|
+
const pending = this.pending.get(frame.id);
|
|
215
|
+
if (!pending)
|
|
216
|
+
return; // stale or unknown id — drop
|
|
217
|
+
if (pending.timer)
|
|
218
|
+
clearTimeout(pending.timer);
|
|
219
|
+
this.pending.delete(frame.id);
|
|
220
|
+
if (frame.ok) {
|
|
221
|
+
pending.resolve(frame.payload);
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
pending.reject(new Error(frame.error?.message ?? `request failed (${frame.error?.code ?? "unknown"})`));
|
|
225
|
+
}
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (frame.type === "event") {
|
|
229
|
+
// Re-emit with the typed event name so on() handlers fire.
|
|
230
|
+
super.emit(frame.event, frame.payload);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
// type === "req" — server doesn't make requests of clients in v1; ignore.
|
|
234
|
+
}
|
|
235
|
+
/* ─────────────────────────── tick watchdog ─────────────────────────── */
|
|
236
|
+
startTickWatch() {
|
|
237
|
+
// Server pushes a state snapshot every TICK_INTERVAL_MS. We expect a
|
|
238
|
+
// frame in 2× that interval. If none arrives, close the socket — the
|
|
239
|
+
// close handler triggers reconnect.
|
|
240
|
+
const checkIntervalMs = TICK_INTERVAL_MS;
|
|
241
|
+
const stallThresholdMs = TICK_INTERVAL_MS * 2;
|
|
242
|
+
this.tickWatchTimer = setInterval(() => {
|
|
243
|
+
if (!this.connected)
|
|
244
|
+
return;
|
|
245
|
+
const gap = Date.now() - this.lastFrameAt;
|
|
246
|
+
if (gap > stallThresholdMs) {
|
|
247
|
+
try {
|
|
248
|
+
this.ws?.close(4000, "tick timeout");
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
/* ignore */
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}, checkIntervalMs);
|
|
255
|
+
this.tickWatchTimer.unref();
|
|
256
|
+
}
|
|
257
|
+
stopTickWatch() {
|
|
258
|
+
if (this.tickWatchTimer)
|
|
259
|
+
clearInterval(this.tickWatchTimer);
|
|
260
|
+
this.tickWatchTimer = undefined;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
//# sourceMappingURL=client.js.map
|