@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.
Files changed (39) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/LICENSE +21 -0
  3. package/README.md +154 -0
  4. package/SECURITY.md +208 -0
  5. package/assets/brigade-wordmark-on-black.png +0 -0
  6. package/assets/brigade-wordmark.png +0 -0
  7. package/brigade.mjs +96 -0
  8. package/dist/cli/chat-cmd.js +120 -0
  9. package/dist/cli/config-cmd.js +132 -0
  10. package/dist/cli/connect-cmd.js +447 -0
  11. package/dist/cli/doctor-cmd.js +317 -0
  12. package/dist/cli/gateway-cmd.js +92 -0
  13. package/dist/cli.js +287 -0
  14. package/dist/core/agent.js +1123 -0
  15. package/dist/core/config.js +80 -0
  16. package/dist/core/console-stream.js +188 -0
  17. package/dist/core/error-classifier.js +354 -0
  18. package/dist/core/event-logger.js +122 -0
  19. package/dist/core/model-caps.js +185 -0
  20. package/dist/core/provider-payload-mutators.js +517 -0
  21. package/dist/core/provider-quirks.js +285 -0
  22. package/dist/core/server.js +459 -0
  23. package/dist/core/smart-compaction.js +209 -0
  24. package/dist/core/system-prompt-defaults.js +88 -0
  25. package/dist/core/system-prompt-guidance.js +269 -0
  26. package/dist/core/system-prompt.js +884 -0
  27. package/dist/index.js +30 -0
  28. package/dist/integrations/ollama.js +140 -0
  29. package/dist/protocol.js +49 -0
  30. package/dist/providers/catalog.js +100 -0
  31. package/dist/providers/validate-key.js +197 -0
  32. package/dist/tui/client.js +263 -0
  33. package/dist/ui/brand-frames-cli.js +20 -0
  34. package/dist/ui/brand-frames.js +36 -0
  35. package/dist/ui/brand.js +402 -0
  36. package/dist/ui/chat.js +929 -0
  37. package/dist/ui/onboarding.js +400 -0
  38. package/dist/ui/theme.js +51 -0
  39. 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