@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,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
|