@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,132 @@
1
+ /**
2
+ * `brigade config` — read & write `~/.brigade/config.json`.
3
+ *
4
+ * brigade config list — print all keys
5
+ * brigade config get <key> — print one value
6
+ * brigade config set <key> <value> — set one value (merges, doesn't overwrite)
7
+ * brigade config unset <key> — remove one key
8
+ *
9
+ * Keys are dotted paths but the schema is flat in v1, so the only valid keys
10
+ * are top-level: defaultProvider, defaultModelId, thinkingLevel,
11
+ * fallbackProvider, fallbackModelId, installedAt. Unknown keys are rejected
12
+ * to keep the file from accumulating typos that silently do nothing.
13
+ */
14
+ import process from "node:process";
15
+ import chalk from "chalk";
16
+ import { loadConfig, saveConfig } from "../core/config.js";
17
+ /** Whitelist of known keys. Keep in sync with BrigadeConfig in core/config.ts. */
18
+ const KNOWN_KEYS = [
19
+ "defaultProvider",
20
+ "defaultModelId",
21
+ "thinkingLevel",
22
+ "fallbackProvider",
23
+ "fallbackModelId",
24
+ "installedAt",
25
+ ];
26
+ /**
27
+ * Dispatch one config subcommand. Splits parsing from I/O so tests can
28
+ * exercise the same logic without spawning a subprocess.
29
+ */
30
+ export async function runConfigCommand(args) {
31
+ const sub = args[0];
32
+ switch (sub) {
33
+ case "list":
34
+ case undefined:
35
+ return listConfig();
36
+ case "get":
37
+ return getConfig(args[1]);
38
+ case "set":
39
+ return setConfig(args[1], args[2]);
40
+ case "unset":
41
+ return unsetConfig(args[1]);
42
+ default:
43
+ process.stderr.write(chalk.red(`unknown config subcommand: ${sub}\n`) +
44
+ chalk.dim("usage: brigade config [list|get <key>|set <key> <value>|unset <key>]\n"));
45
+ return { exitCode: 2 };
46
+ }
47
+ }
48
+ async function listConfig() {
49
+ const cfg = await loadConfig();
50
+ if (Object.keys(cfg).length === 0) {
51
+ process.stderr.write(chalk.dim("(empty — run `brigade onboard` to set up)\n"));
52
+ return { exitCode: 0 };
53
+ }
54
+ for (const key of KNOWN_KEYS) {
55
+ const v = cfg[key];
56
+ if (v === undefined || v === null)
57
+ continue;
58
+ process.stdout.write(`${key}=${String(v)}\n`);
59
+ }
60
+ return { exitCode: 0 };
61
+ }
62
+ async function getConfig(key) {
63
+ if (!key) {
64
+ process.stderr.write(chalk.red("usage: brigade config get <key>\n"));
65
+ return { exitCode: 2 };
66
+ }
67
+ if (!KNOWN_KEYS.includes(key)) {
68
+ process.stderr.write(chalk.red(`unknown key: ${key}\n`) +
69
+ chalk.dim(`valid keys: ${KNOWN_KEYS.join(", ")}\n`));
70
+ return { exitCode: 2 };
71
+ }
72
+ const cfg = await loadConfig();
73
+ const v = cfg[key];
74
+ if (v === undefined) {
75
+ // Empty stdout, exit 1 — matches `git config` semantics for missing keys.
76
+ return { exitCode: 1 };
77
+ }
78
+ process.stdout.write(`${String(v)}\n`);
79
+ return { exitCode: 0 };
80
+ }
81
+ async function setConfig(key, value) {
82
+ if (!key || value === undefined) {
83
+ process.stderr.write(chalk.red("usage: brigade config set <key> <value>\n"));
84
+ return { exitCode: 2 };
85
+ }
86
+ if (!KNOWN_KEYS.includes(key)) {
87
+ process.stderr.write(chalk.red(`unknown key: ${key}\n`) +
88
+ chalk.dim(`valid keys: ${KNOWN_KEYS.join(", ")}\n`));
89
+ return { exitCode: 2 };
90
+ }
91
+ // saveConfig MERGES, so passing a single key preserves the rest. Cast through
92
+ // Partial<BrigadeConfig> so unknown extra keys can't sneak in via this path.
93
+ const partial = { [key]: value };
94
+ await saveConfig(partial);
95
+ process.stderr.write(chalk.green(`set ${key}\n`));
96
+ return { exitCode: 0 };
97
+ }
98
+ async function unsetConfig(key) {
99
+ if (!key) {
100
+ process.stderr.write(chalk.red("usage: brigade config unset <key>\n"));
101
+ return { exitCode: 2 };
102
+ }
103
+ if (!KNOWN_KEYS.includes(key)) {
104
+ process.stderr.write(chalk.red(`unknown key: ${key}\n`) +
105
+ chalk.dim(`valid keys: ${KNOWN_KEYS.join(", ")}\n`));
106
+ return { exitCode: 2 };
107
+ }
108
+ const existing = await loadConfig();
109
+ if (!(key in existing)) {
110
+ // Nothing to do, but report it so scripts can detect.
111
+ process.stderr.write(chalk.dim(`${key} was not set\n`));
112
+ return { exitCode: 0 };
113
+ }
114
+ // saveConfig only merges set fields — to delete one we have to write the
115
+ // remainder explicitly. Read-modify-write through the same merge path so
116
+ // we keep the installedAt-preservation behavior.
117
+ const remainder = { ...existing };
118
+ delete remainder[key];
119
+ // Force overwrite by going through fs directly here would skip the merge
120
+ // guard. Easier path: write the whole remainder via saveConfig — since
121
+ // saveConfig merges, this replaces all fields with the post-delete values,
122
+ // AND any field already absent stays absent. The only edge: installedAt
123
+ // is preserved by saveConfig's defaulting behavior.
124
+ const fsP = await import("node:fs/promises");
125
+ const path = await import("node:path");
126
+ const os = await import("node:os");
127
+ const file = path.join(os.homedir(), ".brigade", "config.json");
128
+ await fsP.writeFile(file, JSON.stringify(remainder, null, 2), "utf8");
129
+ process.stderr.write(chalk.green(`unset ${key}\n`));
130
+ return { exitCode: 0 };
131
+ }
132
+ //# sourceMappingURL=config-cmd.js.map
@@ -0,0 +1,447 @@
1
+ /**
2
+ * `brigade connect` — TUI that talks to a running `brigade gateway`.
3
+ *
4
+ * This is the thin client. The gateway owns the Pi session, runs the full
5
+ * 6-layer wrapper composition, and broadcasts every Pi event back to us.
6
+ * Our job is only to render and to forward the user's input as typed
7
+ * requests over the WebSocket.
8
+ *
9
+ * Feature parity with `brigade chat` for the common path (send a message,
10
+ * stream the reply, see tool calls, abort with Ctrl+C, switch model, set
11
+ * thinking level, compact, exit). Inline `/provider` onboarding is NOT
12
+ * available from connect mode in v1 — onboarding writes to the gateway's
13
+ * filesystem and is out-of-band; the user runs `brigade onboard` against
14
+ * the gateway machine instead.
15
+ */
16
+ import process from "node:process";
17
+ import { ProcessTerminal, TUI, Text, Markdown, CancellableLoader, Editor } from "@mariozechner/pi-tui";
18
+ import chalk from "chalk";
19
+ import { renderBrandHeader } from "../ui/brand.js";
20
+ import { brand, editorTheme, markdownTheme } from "../ui/theme.js";
21
+ import { BrigadeClient } from "../tui/client.js";
22
+ /**
23
+ * Boot the connect TUI. Establishes the WebSocket connection FIRST so the
24
+ * user gets a clear "couldn't reach gateway" error instead of a blank chat.
25
+ */
26
+ export async function runConnectCommand(opts = {}) {
27
+ const host = opts.host ?? "127.0.0.1";
28
+ const port = opts.port ?? 7777;
29
+ const url = `ws://${host}:${port}`;
30
+ const tui = new TUI(new ProcessTerminal());
31
+ tui.start();
32
+ // Wire SIGINT BEFORE the connect attempt so Ctrl+C during a slow connect
33
+ // exits cleanly instead of hanging on the open promise. Re-arm via
34
+ // process.once after each turn-abort so handlers never stack across
35
+ // re-invocations within the same process.
36
+ let chatHandle = null;
37
+ const onSigint = () => {
38
+ if (chatHandle) {
39
+ const wasRunning = chatHandle.abort();
40
+ if (!wasRunning) {
41
+ void chatHandle.close().then(() => {
42
+ tui.stop();
43
+ process.exit(0);
44
+ });
45
+ return;
46
+ }
47
+ process.once("SIGINT", onSigint); // re-arm for the next Ctrl+C
48
+ return;
49
+ }
50
+ tui.stop();
51
+ process.exit(130);
52
+ };
53
+ process.once("SIGINT", onSigint);
54
+ const client = new BrigadeClient({
55
+ url,
56
+ requestTimeoutMs: opts.requestTimeoutMs ?? 60_000,
57
+ });
58
+ try {
59
+ await client.connect();
60
+ }
61
+ catch (err) {
62
+ tui.stop();
63
+ const msg = err instanceof Error ? err.message : String(err);
64
+ const isRefused = /ECONNREFUSED|connect.*refused/i.test(msg);
65
+ const isTimeout = /ETIMEDOUT|timed?\s*out/i.test(msg);
66
+ console.error(chalk.red(`✗ Couldn't reach the Brigade gateway at ${url}: ${msg}`));
67
+ // Differentiate the most common failure modes so users don't waste
68
+ // time debugging the wrong cause.
69
+ if (isRefused) {
70
+ console.error(chalk.dim(` Likely cause: no gateway is running on port ${port}.`));
71
+ console.error(chalk.dim(` Either start one: brigade gateway --port ${port}`));
72
+ console.error(chalk.dim(` Or, if it's on another port: brigade connect --port <that-port>`));
73
+ }
74
+ else if (isTimeout) {
75
+ console.error(chalk.dim(` Likely cause: gateway is reachable but slow to handshake.`));
76
+ console.error(chalk.dim(` Check that ${host} resolves and the port isn't blocked by a firewall.`));
77
+ }
78
+ else {
79
+ console.error(chalk.dim(` Start the gateway first: brigade gateway --port ${port}`));
80
+ console.error(chalk.dim(` Or check the host/port flags: brigade connect --host ${host} --port ${port}`));
81
+ }
82
+ process.exit(1);
83
+ }
84
+ chatHandle = await wireConnectUi(tui, client);
85
+ return chatHandle;
86
+ }
87
+ /**
88
+ * Build the live chat UI on top of an already-connected client. Split out so
89
+ * tests can inject a pre-connected client without going through CLI flag
90
+ * parsing or process.exit.
91
+ */
92
+ export async function wireConnectUi(tui, client) {
93
+ renderBrandHeader(tui);
94
+ const header = new Text("", 0, 0);
95
+ tui.addChild(header);
96
+ const divider = new Text(brand.dim("─".repeat(80)), 0, 0);
97
+ tui.addChild(divider);
98
+ // Cumulative usage — accumulated from state snapshots so a reconnect picks
99
+ // up where we left off instead of zeroing the totals on the user's screen.
100
+ let lastSnapshot = null;
101
+ let isAgentRunning = false;
102
+ let activeAssistant = null;
103
+ let activeLoader = null;
104
+ const pendingTools = new Map();
105
+ const updateHeader = (extra) => {
106
+ const provider = lastSnapshot?.provider ?? "?";
107
+ const modelId = lastSnapshot?.modelId ?? "?";
108
+ const tokens = lastSnapshot && (lastSnapshot.totalTokensIn + lastSnapshot.totalTokensOut) > 0
109
+ ? ` · ${(lastSnapshot.totalTokensIn + lastSnapshot.totalTokensOut).toLocaleString()} tok`
110
+ : "";
111
+ const cost = lastSnapshot && lastSnapshot.totalCostUsd > 0
112
+ ? ` · $${lastSnapshot.totalCostUsd.toFixed(4)}`
113
+ : "";
114
+ const usage = lastSnapshot?.contextUsagePercent ?? null;
115
+ let usageStr = "";
116
+ if (usage != null && usage >= 50) {
117
+ const pct = Math.round(usage);
118
+ const colored = pct >= 75 ? brand.amber(`${pct}% ctx`) : brand.dim(`${pct}% ctx`);
119
+ usageStr = ` · ${colored}`;
120
+ }
121
+ const tail = extra ? ` · ${extra}` : "";
122
+ const dot = isAgentRunning ? brand.amber("●") : brand.dim("○");
123
+ header.setText(` ${dot} ${brand.white("Brigade")} ${brand.dim(`${provider} · ${modelId}${tokens}${cost}`)}${usageStr}${brand.dim(tail)}`);
124
+ };
125
+ updateHeader();
126
+ const editor = new Editor(tui, editorTheme);
127
+ tui.addChild(editor);
128
+ tui.setFocus(editor);
129
+ tui.addChild(new Text(brand.dim(" Enter to send · Ctrl+C abort · Ctrl+D quit · /model /thinking /compact /help"), 0, 0));
130
+ const insertBeforeEditor = (component) => {
131
+ const children = tui.children;
132
+ const editorIdx = children.indexOf(editor);
133
+ if (editorIdx < 0) {
134
+ tui.addChild(component);
135
+ }
136
+ else {
137
+ children.splice(editorIdx, 0, component);
138
+ tui.requestRender();
139
+ }
140
+ };
141
+ const removeChild = (component) => {
142
+ try {
143
+ tui.removeChild(component);
144
+ }
145
+ catch {
146
+ /* ignore */
147
+ }
148
+ };
149
+ const extractAssistantText = (message) => {
150
+ if (!message || !Array.isArray(message.content))
151
+ return "";
152
+ return message.content
153
+ .filter((b) => b && b.type === "text" && typeof b.text === "string")
154
+ .map((b) => b.text)
155
+ .join("");
156
+ };
157
+ // State snapshots from the gateway — every mutation pushes one.
158
+ client.on("state", (snap) => {
159
+ lastSnapshot = snap;
160
+ isAgentRunning = snap.isAgentRunning;
161
+ updateHeader();
162
+ });
163
+ // Server-side warnings/info (e.g. "primary failed, trying fallback") — the
164
+ // gateway emits these via the wrapper-chain callbacks. Mirror to the TUI
165
+ // so the user sees the same context they would in `brigade chat`.
166
+ client.on("log", (entry) => {
167
+ const tone = entry.level === "error"
168
+ ? brand.error(`✗ ${entry.message}`)
169
+ : entry.level === "warn"
170
+ ? brand.dim(`⚠ ${entry.message}`)
171
+ : brand.dim(`↻ ${entry.message}`);
172
+ insertBeforeEditor(new Text(` ${tone}`, 0, 0));
173
+ });
174
+ // Pi events are forwarded as `{ event: <pi event> }`. Same render logic
175
+ // as src/ui/chat.ts but stripped of in-process state mutations.
176
+ client.on("pi", ({ event }) => {
177
+ switch (event?.type) {
178
+ case "agent_start": {
179
+ isAgentRunning = true;
180
+ editor.disableSubmit = true;
181
+ updateHeader("thinking…");
182
+ activeLoader = new CancellableLoader(tui, (s) => brand.amber(s), (s) => brand.dim(s), "thinking");
183
+ insertBeforeEditor(activeLoader);
184
+ break;
185
+ }
186
+ case "message_update":
187
+ case "message_end": {
188
+ const msg = event.message;
189
+ if (!msg || msg.role !== "assistant")
190
+ break;
191
+ const text = extractAssistantText(msg);
192
+ if (!text)
193
+ break;
194
+ if (activeLoader) {
195
+ removeChild(activeLoader);
196
+ activeLoader = null;
197
+ }
198
+ if (!activeAssistant) {
199
+ activeAssistant = new Markdown(`${brand.agent("brigade")} ${text}`, 1, 0, markdownTheme);
200
+ insertBeforeEditor(activeAssistant);
201
+ }
202
+ else {
203
+ activeAssistant.setText(`${brand.agent("brigade")} ${text}`);
204
+ tui.requestRender();
205
+ }
206
+ break;
207
+ }
208
+ case "tool_execution_start": {
209
+ if (activeLoader) {
210
+ removeChild(activeLoader);
211
+ activeLoader = null;
212
+ }
213
+ const indicator = new Text(` ${brand.tool("⚡")} ${brand.tool(event.toolName)}`, 0, 0);
214
+ pendingTools.set(event.toolCallId, indicator);
215
+ insertBeforeEditor(indicator);
216
+ break;
217
+ }
218
+ case "tool_execution_end": {
219
+ const indicator = pendingTools.get(event.toolCallId);
220
+ if (indicator) {
221
+ const mark = event.isError ? brand.error("✗") : brand.tool("✓");
222
+ indicator.setText(` ${mark} ${brand.tool(event.toolName)}`);
223
+ tui.requestRender();
224
+ pendingTools.delete(event.toolCallId);
225
+ }
226
+ break;
227
+ }
228
+ case "turn_end": {
229
+ // Intentionally a no-op. Token totals (totalTokensIn / Out /
230
+ // CostUsd) are accumulated SERVER-SIDE in server.ts:156-163
231
+ // on every turn_end, then pushed to us via the very next
232
+ // `state` event at server.ts:165. Our `state` handler above
233
+ // stores that into `lastSnapshot` and calls updateHeader(),
234
+ // which reads the totals from `lastSnapshot`. Doing the
235
+ // accumulation HERE too would double-count.
236
+ //
237
+ // Kept as a labelled case so any future connect-only logic
238
+ // (e.g. per-turn flash UI, telemetry hook) has a place to
239
+ // land — and so the next audit doesn't flag this as missing.
240
+ break;
241
+ }
242
+ case "compaction_start": {
243
+ const pct = lastSnapshot?.contextUsagePercent != null
244
+ ? `${Math.round(lastSnapshot.contextUsagePercent)}%`
245
+ : "high";
246
+ insertBeforeEditor(new Text(` ${brand.dim(`⚡ compacting context (was ${pct})…`)}`, 0, 0));
247
+ break;
248
+ }
249
+ case "compaction_end": {
250
+ if (event.aborted) {
251
+ insertBeforeEditor(new Text(` ${brand.dim("compaction aborted")}`, 0, 0));
252
+ }
253
+ else {
254
+ // Pi's getContextUsage returns null right after compaction by
255
+ // design — token estimates need a fresh LLM response. Show
256
+ // that explicitly via the snapshot's percent (server pushes
257
+ // a fresh snapshot post-compact).
258
+ const after = lastSnapshot?.contextUsagePercent;
259
+ const afterStr = after != null
260
+ ? `usage now ${Math.round(after)}%`
261
+ : "usage refreshes after your next message";
262
+ insertBeforeEditor(new Text(` ${brand.amber("✓")} ${brand.dim(`compacted · ${afterStr}`)}`, 0, 0));
263
+ }
264
+ break;
265
+ }
266
+ case "auto_retry_start": {
267
+ // Pi auto-retries transient provider errors (rate limit, 5xx,
268
+ // connection drop). Tell the user it's happening — without this,
269
+ // a slow retry looks like the connection is just hanging.
270
+ const attempt = event.attempt ?? 1;
271
+ const max = event.maxAttempts ?? 1;
272
+ const waitS = Math.round((event.delayMs ?? 0) / 100) / 10;
273
+ insertBeforeEditor(new Text(` ${brand.dim(`↻ retrying (attempt ${attempt}/${max}, waiting ${waitS}s)…`)}`, 0, 0));
274
+ break;
275
+ }
276
+ case "auto_retry_end": {
277
+ if (event.success === false) {
278
+ insertBeforeEditor(new Text(` ${brand.error("✗")} ${brand.error(`gave up after ${event.attempt} attempts`)}`, 0, 0));
279
+ }
280
+ break;
281
+ }
282
+ case "agent_end": {
283
+ isAgentRunning = false;
284
+ editor.disableSubmit = false;
285
+ activeAssistant = null;
286
+ if (activeLoader) {
287
+ removeChild(activeLoader);
288
+ activeLoader = null;
289
+ }
290
+ updateHeader();
291
+ break;
292
+ }
293
+ }
294
+ });
295
+ // Reconnect notifications — let the user know what just happened so a
296
+ // dropped/restored gateway doesn't look like phantom output.
297
+ client.on("reconnected", () => {
298
+ insertBeforeEditor(new Text(` ${brand.dim("↻ reconnected to gateway")}`, 0, 0));
299
+ });
300
+ // Slash command + send wiring.
301
+ editor.onSubmit = async (value) => {
302
+ const trimmed = value.trim();
303
+ if (!trimmed)
304
+ return;
305
+ // Local commands first — never reach the gateway.
306
+ if (trimmed === "/exit" || trimmed === "/quit") {
307
+ client.close();
308
+ tui.stop();
309
+ process.exit(0);
310
+ }
311
+ if (trimmed === "/help") {
312
+ editor.setText("");
313
+ insertBeforeEditor(new Markdown(`${brand.dim("commands")}\n` +
314
+ `- ${chalk.bold("/exit")} or ${chalk.bold("/quit")} — disconnect & quit\n` +
315
+ `- ${chalk.bold("/help")} — this list\n` +
316
+ `- ${chalk.bold("/model <id>")} — switch to a configured model on the gateway\n` +
317
+ `- ${chalk.bold("/thinking <level>")} — set reasoning effort (off|minimal|low|medium|high|xhigh)\n` +
318
+ `- ${chalk.bold("/compact")} — summarize older turns to free up context\n` +
319
+ `- ${chalk.bold("Ctrl+C")} — abort the current turn\n` +
320
+ `- ${chalk.bold("Ctrl+D")} — quit\n\n` +
321
+ brand.dim("To add a new provider, run `brigade onboard` on the gateway machine."), 1, 0, markdownTheme));
322
+ return;
323
+ }
324
+ // Mid-turn submit → STEER. The gateway has the same Pi semantics; queueing
325
+ // the message lets the model see it on the next iteration without abort.
326
+ if (isAgentRunning) {
327
+ editor.setText("");
328
+ try {
329
+ await client.request("steer", { text: trimmed });
330
+ insertBeforeEditor(new Markdown(`${brand.user("you")} ${trimmed}`, 1, 0, markdownTheme));
331
+ insertBeforeEditor(new Text(` ${brand.dim("↳ queued — the model will see this on its next turn")}`, 0, 0));
332
+ }
333
+ catch (err) {
334
+ const msg = err instanceof Error ? err.message : String(err);
335
+ insertBeforeEditor(new Text(` ${brand.error("✗")} ${brand.error(msg)}`, 0, 0));
336
+ }
337
+ return;
338
+ }
339
+ // /compact — same long-run rationale as `prompt` above; compaction
340
+ // can take a while on a big transcript, beyond the default 60s.
341
+ if (trimmed === "/compact") {
342
+ editor.setText("");
343
+ insertBeforeEditor(new Text(` ${brand.dim("Compacting…")}`, 0, 0));
344
+ try {
345
+ await client.request("compact", undefined, { timeoutMs: 0 });
346
+ insertBeforeEditor(new Text(` ${brand.amber("✓")} ${brand.dim("Compacted")}`, 0, 0));
347
+ }
348
+ catch (err) {
349
+ const msg = err instanceof Error ? err.message : String(err);
350
+ insertBeforeEditor(new Text(` ${brand.error("✗")} ${brand.error(`Compaction failed: ${msg}`)}`, 0, 0));
351
+ }
352
+ return;
353
+ }
354
+ // /model <id> — switch by id (the gateway resolves provider via its registry)
355
+ if (trimmed === "/model" || trimmed.startsWith("/model ")) {
356
+ editor.setText("");
357
+ let models;
358
+ try {
359
+ models = await client.request("list-models");
360
+ }
361
+ catch (err) {
362
+ insertBeforeEditor(new Text(` ${brand.error("✗")} ${brand.error(err instanceof Error ? err.message : String(err))}`, 0, 0));
363
+ return;
364
+ }
365
+ const arg = trimmed === "/model" ? "" : trimmed.slice("/model ".length).trim();
366
+ if (!arg) {
367
+ const list = models
368
+ .map((m) => ` ${brand.dim(m.provider)} ${brand.white(m.id)}`)
369
+ .join("\n");
370
+ insertBeforeEditor(new Markdown(`${brand.dim("configured models on the gateway:")}\n${list}\n\n${brand.dim("usage: /model <id>")}`, 1, 0, markdownTheme));
371
+ return;
372
+ }
373
+ // Prefer current provider on tie.
374
+ const currentProvider = lastSnapshot?.provider;
375
+ const matches = models.filter((m) => m.id === arg);
376
+ const target = matches.find((m) => m.provider === currentProvider) ?? matches[0];
377
+ if (!target) {
378
+ insertBeforeEditor(new Text(` ${brand.error(`✗ no model with id "${arg}" on the gateway.`)}`, 0, 0));
379
+ return;
380
+ }
381
+ try {
382
+ await client.request("set-model", { provider: target.provider, modelId: target.id });
383
+ insertBeforeEditor(new Text(` ${brand.amber("✓")} ${brand.dim("switched to")} ${brand.white(`${target.provider} · ${target.id}`)}`, 0, 0));
384
+ }
385
+ catch (err) {
386
+ const msg = err instanceof Error ? err.message : String(err);
387
+ insertBeforeEditor(new Text(` ${brand.error("✗")} ${brand.error(msg)}`, 0, 0));
388
+ }
389
+ return;
390
+ }
391
+ // /thinking [level]
392
+ if (trimmed === "/thinking" || trimmed.startsWith("/thinking ")) {
393
+ editor.setText("");
394
+ const arg = trimmed === "/thinking" ? "" : trimmed.slice("/thinking ".length).trim().toLowerCase();
395
+ if (!arg) {
396
+ const cur = lastSnapshot?.thinkingLevel ?? "?";
397
+ const avail = lastSnapshot?.availableThinkingLevels ?? [];
398
+ insertBeforeEditor(new Text(` ${brand.dim("thinking is")} ${brand.amber(cur)} ${brand.dim("· available:")} ${brand.dim(avail.join(" "))}`, 0, 0));
399
+ return;
400
+ }
401
+ try {
402
+ await client.request("set-thinking", { level: arg });
403
+ insertBeforeEditor(new Text(` ${brand.amber("✓")} ${brand.dim("thinking set to")} ${brand.amber(arg)}`, 0, 0));
404
+ }
405
+ catch (err) {
406
+ const msg = err instanceof Error ? err.message : String(err);
407
+ insertBeforeEditor(new Text(` ${brand.error("✗")} ${brand.error(msg)}`, 0, 0));
408
+ }
409
+ return;
410
+ }
411
+ // Default — send as a prompt. Override timeout to 0 (disabled): the
412
+ // SERVER bounds turn duration via the 6-layer wrapper chain (heartbeat,
413
+ // stream-timeout, length-continuation). A client-side 60s cap would
414
+ // reject WHILE the server keeps processing, producing silent state
415
+ // desync — next user message would interleave with the in-flight reply.
416
+ insertBeforeEditor(new Markdown(`${brand.user("you")} ${trimmed}`, 1, 0, markdownTheme));
417
+ editor.setText("");
418
+ try {
419
+ await client.request("prompt", { text: trimmed }, { timeoutMs: 0 });
420
+ }
421
+ catch (err) {
422
+ const msg = err instanceof Error ? err.message : String(err);
423
+ insertBeforeEditor(new Text(` ${brand.error("✗")} ${brand.error(msg)}`, 0, 0));
424
+ }
425
+ };
426
+ tui.requestRender();
427
+ return {
428
+ abort: () => {
429
+ if (!isAgentRunning)
430
+ return false;
431
+ void client.request("abort").catch(() => { });
432
+ isAgentRunning = false;
433
+ editor.disableSubmit = false;
434
+ if (activeLoader) {
435
+ removeChild(activeLoader);
436
+ activeLoader = null;
437
+ }
438
+ insertBeforeEditor(new Text(` ${brand.error("✗")} ${brand.dim("aborted")}`, 0, 0));
439
+ updateHeader();
440
+ return true;
441
+ },
442
+ close: async () => {
443
+ client.close();
444
+ },
445
+ };
446
+ }
447
+ //# sourceMappingURL=connect-cmd.js.map