@spinabot/brigade 0.1.0 → 0.1.2

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.
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Single source of truth for the Brigade CLI version.
3
+ *
4
+ * Lives in core/ (rather than being re-imported from cli.ts) so subsystems
5
+ * that don't pull cli.ts (brigade-config.ts's meta.lastTouchedVersion stamp,
6
+ * future telemetry/diagnostics, the gateway protocol handshake) can read the
7
+ * version without dragging the entire CLI dispatcher into their import graph.
8
+ *
9
+ * Keep in lockstep with package.json::version. There's no automated check —
10
+ * a missed bump just means meta.lastTouchedVersion lies, which is annoying
11
+ * but not corrupting.
12
+ */
13
+ export const BRIGADE_CLI_VERSION = "0.1.0";
14
+ //# sourceMappingURL=version.js.map
package/dist/index.js CHANGED
@@ -9,20 +9,22 @@
9
9
  import process from "node:process";
10
10
  import { pathToFileURL } from "node:url";
11
11
  import { runCli } from "./cli.js";
12
+ import { installTerminalCleanupHandler, restoreTerminal } from "./ui/terminal-cleanup.js";
12
13
  const entry = process.argv[1] ? pathToFileURL(process.argv[1]).href : "";
13
14
  if (import.meta.url === entry) {
15
+ // Belt-and-braces: the `process.on("exit")` listener catches every code
16
+ // path, but we also call restoreTerminal() explicitly in the catch and
17
+ // finally blocks so the bytes are flushed BEFORE Node tears the streams
18
+ // down (some terminals miss late writes from `exit` listeners).
19
+ installTerminalCleanupHandler();
14
20
  runCli(process.argv.slice(2))
15
21
  .then((code) => {
22
+ restoreTerminal();
16
23
  if (typeof code === "number")
17
24
  process.exit(code);
18
25
  })
19
26
  .catch((err) => {
20
- try {
21
- process.stdout.write("\x1b[?25h\x1b[?1049l");
22
- }
23
- catch {
24
- /* terminal already gone */
25
- }
27
+ restoreTerminal();
26
28
  console.error(`Brigade crashed: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
27
29
  process.exit(1);
28
30
  });
package/dist/protocol.js CHANGED
@@ -32,6 +32,21 @@ export function modelToSummary(model) {
32
32
  /* ─────────────────────────── shared constants ─────────────────────────── */
33
33
  /** Default port. Configurable via BRIGADE_PORT env var. */
34
34
  export const DEFAULT_PORT = 7777;
35
+ /**
36
+ * Process exit codes — sysexits-aligned so supervisors (systemd, launchd,
37
+ * Docker) make the right restart decisions:
38
+ * - 1 = generic failure (default; supervisor will retry)
39
+ * - 2 = usage error (bad CLI args; retry will fail again)
40
+ * - 78 = configuration error (sysexits EX_CONFIG; supervisor STOPS
41
+ * retrying because restarting won't fix a bad config)
42
+ *
43
+ * Use EXIT_CONFIG_ERROR for "the config is missing or invalid" — without it,
44
+ * a misconfigured `brigade gateway` under systemd would restart-storm.
45
+ */
46
+ export const EXIT_OK = 0;
47
+ export const EXIT_FAILURE = 1;
48
+ export const EXIT_USAGE_ERROR = 2;
49
+ export const EXIT_CONFIG_ERROR = 78;
35
50
  /**
36
51
  * Tick interval for the heartbeat. Server pushes a `pi`-wrapped tick event
37
52
  * every TICK_INTERVAL_MS so the client can detect a stalled connection.
package/dist/ui/brand.js CHANGED
@@ -303,24 +303,32 @@ function composeFrame(frameIdx, termCols) {
303
303
  return lines.join("\n");
304
304
  }
305
305
  /**
306
- * Render the wordmark + animated video clip + tagline. Spawns a setInterval
306
+ * Render the wordmark + video clip + tagline. By default spawns a setInterval
307
307
  * that drives the animation; the frame index wraps via modulo so the clip
308
- * loops continuously for the rest of the session.
308
+ * loops continuously for the rest of the session. Pass `{ animate: false }` to
309
+ * render a single still frame (the last frame, which is the intended "hold"
310
+ * state) and skip the interval entirely — used by the chat surface where the
311
+ * looping clip would compete with the conversation for attention.
309
312
  *
310
313
  * Returns the components added to the TUI (caller can use these to remove or
311
314
  * inspect them).
312
315
  */
313
- export function renderBrandHeader(tui) {
316
+ export function renderBrandHeader(tui, opts = {}) {
317
+ const animate = opts.animate ?? true;
314
318
  const components = [];
315
319
  // Pi-TUI's TUI exposes the underlying Terminal which has a `columns` getter
316
320
  // (see node_modules/@mariozechner/pi-tui/dist/terminal.d.ts). Prefer that
317
321
  // over process.stdout.columns since it's the value pi-tui itself uses for
318
322
  // rendering, but pickLayout falls back if either is unavailable.
319
323
  const getCols = () => tui.terminal?.columns ?? process.stdout.columns;
324
+ // Static-mode start frame is the LAST frame — the artist's intended resting
325
+ // pose (see header docstring "the last frame holds"). Animated mode starts
326
+ // at frame 0 and advances from there.
327
+ const initialFrameIdx = animate ? 0 : VIDEO_FRAME_COUNT - 1;
320
328
  const padTop = new Text("\n\n", 0, 0);
321
329
  tui.addChild(padTop);
322
330
  components.push(padTop);
323
- const block = new Text(composeFrame(0, getCols()), 0, 0);
331
+ const block = new Text(composeFrame(initialFrameIdx, getCols()), 0, 0);
324
332
  tui.addChild(block);
325
333
  components.push(block);
326
334
  const padBottom = new Text("", 0, 0);
@@ -379,20 +387,28 @@ export function renderBrandHeader(tui) {
379
387
  // of the session — no early-exit clearInterval. We refetch the terminal
380
388
  // width on every tick so resizes are reflected within ~1/VIDEO_FPS s
381
389
  // even if the dedicated resize listener below misses for some reason.
382
- let frameIdx = 0;
383
- const tickMs = Math.max(1, Math.round(1000 / VIDEO_FPS));
384
- const timer = setInterval(() => {
385
- frameIdx = (frameIdx + 1) % VIDEO_FRAME_COUNT;
386
- safeRender(composeFrame(frameIdx, getCols()));
387
- }, tickMs);
388
- // Don't keep the event loop alive just for this animation — once the user
389
- // quits, the process should be free to exit even if the timer hasn't yet
390
- // reached the final frame.
391
- timer.unref?.();
390
+ //
391
+ // In static mode (animate: false) we skip the setInterval entirely — the
392
+ // initial composeFrame(initialFrameIdx) above already painted the still
393
+ // frame, so there's nothing to drive. The resize handler still runs so the
394
+ // still frame re-lays-out (side / stack / ascii-only / empty) on resize.
395
+ let frameIdx = initialFrameIdx;
396
+ if (animate) {
397
+ const tickMs = Math.max(1, Math.round(1000 / VIDEO_FPS));
398
+ const timer = setInterval(() => {
399
+ frameIdx = (frameIdx + 1) % VIDEO_FRAME_COUNT;
400
+ safeRender(composeFrame(frameIdx, getCols()));
401
+ }, tickMs);
402
+ // Don't keep the event loop alive just for this animation — once the user
403
+ // quits, the process should be free to exit even if the timer hasn't yet
404
+ // reached the final frame.
405
+ timer.unref?.();
406
+ }
392
407
  // Re-compose the brand block on terminal resize so the responsive layout
393
408
  // (side / stack / ascii-only / empty) keeps working as the animation
394
409
  // loops forever. During the animation the safeRender identical-frame
395
- // cache makes this a no-op when the layout didn't actually change.
410
+ // cache makes this a no-op when the layout didn't actually change. In
411
+ // static mode this is the only thing that ever re-renders the block.
396
412
  const onResize = () => {
397
413
  safeRender(composeFrame(frameIdx, getCols()));
398
414
  };
package/dist/ui/chat.js CHANGED
@@ -18,10 +18,12 @@ import chalk from "chalk";
18
18
  import { classifySensitiveStopReason, runWithContentQualityRetry, runWithFallback, runWithHeartbeat, runWithLengthContinuation, runWithStreamTimeout, runWithThinkingFallback, switchModelMidTurn, } from "../core/agent.js";
19
19
  import { BRIGADE_DIR, loadConfig, saveConfig } from "../core/config.js";
20
20
  import { cleanProviderError, describeModelCapabilities, pickStreamIdleMs } from "../core/model-caps.js";
21
+ import { buildLoginGuidanceMessage, friendlyError, translateAuthError } from "../core/auth-error.js";
21
22
  import { discoverOllamaModels, writeOllamaToModelsJson } from "../integrations/ollama.js";
22
23
  import { findProvider, PROVIDERS } from "../providers/catalog.js";
23
24
  import { validateApiKeyOnline } from "../providers/validate-key.js";
24
25
  import { renderBrandHeader } from "./brand.js";
26
+ import { restoreTerminal } from "./terminal-cleanup.js";
25
27
  import { brand, editorTheme, markdownTheme, selectListTheme } from "./theme.js";
26
28
  const VALID_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"];
27
29
  export async function runChat(opts) {
@@ -31,8 +33,12 @@ export async function runChat(opts) {
31
33
  // ── brand wordmark at the top of the chat screen ────────────────────
32
34
  // Same chunky 4-stop metallic wordmark from onboarding. Pi-TUI is
33
35
  // stream-based, so this appears once at boot and naturally scrolls
34
- // out of view as the conversation grows below.
35
- renderBrandHeader(tui);
36
+ // out of view as the conversation grows below. We render the still
37
+ // (last-frame) variant here — onboarding gets the looping clip as a
38
+ // one-time wow moment, but in chat the looping animation competes
39
+ // with the conversation for attention, so we freeze it at the
40
+ // artist's intended resting pose.
41
+ renderBrandHeader(tui, { animate: false });
36
42
  // ── status header (model · tokens · cost) ───────────────────────────
37
43
  const header = new Text("", 0, 0);
38
44
  tui.addChild(header);
@@ -217,8 +223,9 @@ export async function runChat(opts) {
217
223
  await session.setModel(model);
218
224
  }
219
225
  catch (err) {
220
- const msg = err instanceof Error ? err.message : String(err);
221
- insertBeforeEditor(new Text(` ${brand.error("✗")} ${brand.error(msg)}`, 0, 0));
226
+ const raw = err instanceof Error ? err.message : String(err);
227
+ const friendly = friendlyError(raw, cleanProviderError);
228
+ insertBeforeEditor(new Text(` ${brand.error("✗")} ${brand.error(friendly)}`, 0, 0));
222
229
  return false;
223
230
  }
224
231
  provider = newProvider;
@@ -392,8 +399,20 @@ export async function runChat(opts) {
392
399
  // to a friendly message — without this the user sees nothing.
393
400
  const sensitive = !text ? classifySensitiveStopReason(last) : null;
394
401
  if (!text && errMsg) {
395
- const cleaned = cleanProviderError(errMsg);
396
- insertBeforeEditor(new Text(` ${brand.error("✗")} ${brand.error(cleaned)}`, 0, 0));
402
+ // Pi's auth-resolution failures arrive with `/login` (a Pi command
403
+ // Brigade doesn't have) and raw `node_modules/.../docs/...` paths
404
+ // embedded — both are Brigade-UX violations. friendlyError() runs
405
+ // translateAuthError first (which carries no `✗` prefix because
406
+ // it includes its own `⚠`), then falls back to cleanProviderError
407
+ // for non-auth provider errors (with `✗` prefix).
408
+ const translated = translateAuthError(errMsg);
409
+ if (translated) {
410
+ insertBeforeEditor(new Text(` ${brand.error(translated)}`, 0, 0));
411
+ }
412
+ else {
413
+ const cleaned = cleanProviderError(errMsg);
414
+ insertBeforeEditor(new Text(` ${brand.error("✗")} ${brand.error(cleaned)}`, 0, 0));
415
+ }
397
416
  }
398
417
  else if (sensitive) {
399
418
  insertBeforeEditor(new Text(` ${brand.error("✗")} ${brand.error(sensitive.userMessage)}`, 0, 0));
@@ -441,6 +460,7 @@ export async function runChat(opts) {
441
460
  // post-loop set; if a new local command lands below, mirror it here.)
442
461
  if (trimmed === "/exit" || trimmed === "/quit") {
443
462
  tui.stop();
463
+ restoreTerminal();
444
464
  process.exit(0);
445
465
  }
446
466
  // /model <id> mid-turn → live model switch. Aborts the current
@@ -479,7 +499,9 @@ export async function runChat(opts) {
479
499
  updateHeader();
480
500
  }
481
501
  catch (err) {
482
- insertBeforeEditor(new Text(` ${brand.error("✗")} ${brand.error(`Switch failed: ${err instanceof Error ? err.message : String(err)}`)}`, 0, 0));
502
+ const raw = err instanceof Error ? err.message : String(err);
503
+ const friendly = friendlyError(raw, cleanProviderError);
504
+ insertBeforeEditor(new Text(` ${brand.error("✗")} ${brand.error(`Switch failed: ${friendly}`)}`, 0, 0));
483
505
  }
484
506
  return;
485
507
  }
@@ -495,6 +517,7 @@ export async function runChat(opts) {
495
517
  // slash commands (handled locally, never sent to the model)
496
518
  if (trimmed === "/exit" || trimmed === "/quit") {
497
519
  tui.stop();
520
+ restoreTerminal();
498
521
  process.exit(0);
499
522
  }
500
523
  if (trimmed === "/clear") {
@@ -516,6 +539,15 @@ export async function runChat(opts) {
516
539
  editor.setText("");
517
540
  return;
518
541
  }
542
+ // `/login` is a Pi slash command — Brigade doesn't have one. Pi's
543
+ // auth-error text tells users to type `/login`, so users WILL try it.
544
+ // Translate the attempt into Brigade's actual flow rather than
545
+ // silently sending `/login` to the model as a user message.
546
+ if (trimmed === "/login" || trimmed === "/auth" || trimmed === "/onboard") {
547
+ editor.setText("");
548
+ insertBeforeEditor(new Text(` ${brand.error(buildLoginGuidanceMessage())}`, 0, 0));
549
+ return;
550
+ }
519
551
  // /compact — manually trigger Pi's compaction. Auto-compaction runs in
520
552
  // the background at high context usage; this lets the user trigger it
521
553
  // on demand (or summarize early before a long task).
@@ -541,7 +573,9 @@ export async function runChat(opts) {
541
573
  updateHeader();
542
574
  }
543
575
  catch (err) {
544
- insertBeforeEditor(new Text(` ${brand.error("✗")} ${brand.error(`Compaction failed: ${err instanceof Error ? err.message : String(err)}`)}`, 0, 0));
576
+ const raw = err instanceof Error ? err.message : String(err);
577
+ const friendly = friendlyError(raw, cleanProviderError);
578
+ insertBeforeEditor(new Text(` ${brand.error("✗")} ${brand.error(`Compaction failed: ${friendly}`)}`, 0, 0));
545
579
  }
546
580
  return;
547
581
  }
@@ -605,6 +639,16 @@ export async function runChat(opts) {
605
639
  // Show only providers we don't already have credentials/registration for.
606
640
  const configuredProviders = new Set(modelRegistry.getAvailable().map((m) => m.provider));
607
641
  const candidates = PROVIDERS.filter((p) => !configuredProviders.has(p.id));
642
+ // Track whether this add results in 2+ providers being configured —
643
+ // drives the "all providers stay configured · use /model to switch"
644
+ // reassurance line shown after a successful add. Captured BEFORE the
645
+ // add so we know the prior count.
646
+ const willBeMultiProvider = configuredProviders.size >= 1;
647
+ const renderMultiProviderTip = () => {
648
+ if (!willBeMultiProvider)
649
+ return;
650
+ insertBeforeEditor(new Text(` ${brand.dim("All your providers stay configured · use /model to switch any time · saved across restarts.")}`, 0, 0));
651
+ };
608
652
  if (candidates.length === 0) {
609
653
  insertBeforeEditor(new Text(` ${brand.dim("all curated providers are already configured. Use /model to switch.")}`, 0, 0));
610
654
  return;
@@ -705,6 +749,7 @@ export async function runChat(opts) {
705
749
  return;
706
750
  }
707
751
  insertBeforeEditor(new Text(` ${brand.amber("✓")} ${brand.dim(`Connected ${providerId} · switching to ${oneModelId}…`)}`, 0, 0));
752
+ renderMultiProviderTip();
708
753
  await switchToModel(providerId, oneModelId);
709
754
  return;
710
755
  }
@@ -728,6 +773,7 @@ export async function runChat(opts) {
728
773
  return;
729
774
  }
730
775
  insertBeforeEditor(new Text(` ${brand.amber("✓")} ${brand.dim(`Ollama connected · ${discovered.length} model${discovered.length === 1 ? "" : "s"} ready. Switching…`)}`, 0, 0));
776
+ renderMultiProviderTip();
731
777
  // Auto-switch to the first discovered model.
732
778
  if (discovered[0])
733
779
  await switchToModel("ollama", discovered[0].id);
@@ -757,6 +803,7 @@ export async function runChat(opts) {
757
803
  const newModels = modelRegistry.getAvailable().filter((m) => m.provider === picked.id);
758
804
  if (newModels.length === 0) {
759
805
  insertBeforeEditor(new Text(` ${brand.amber("✓")} ${brand.dim(`${picked.name} connected, but no models are available right now. Try /model later.`)}`, 0, 0));
806
+ renderMultiProviderTip();
760
807
  return;
761
808
  }
762
809
  const sorted = [...newModels].sort((a, b) => {
@@ -765,6 +812,7 @@ export async function runChat(opts) {
765
812
  return (b.contextWindow ?? 0) - (a.contextWindow ?? 0);
766
813
  });
767
814
  insertBeforeEditor(new Text(` ${brand.amber("✓")} ${brand.dim(`${picked.name} connected · switching to ${sorted[0].id}…`)}`, 0, 0));
815
+ renderMultiProviderTip();
768
816
  await switchToModel(picked.id, sorted[0].id);
769
817
  return;
770
818
  }
@@ -859,10 +907,10 @@ export async function runChat(opts) {
859
907
  },
860
908
  }),
861
909
  onFallback: (reason) => {
862
- insertBeforeEditor(new Text(` ${brand.dim(`↻ primary failed (${cleanProviderError(reason)}) — trying ${cfg.fallbackModelId}…`)}`, 0, 0));
910
+ insertBeforeEditor(new Text(` ${brand.dim(`↻ primary failed (${friendlyError(reason, cleanProviderError)}) — trying ${cfg.fallbackModelId}…`)}`, 0, 0));
863
911
  },
864
912
  onFallbackExhausted: (reason) => {
865
- insertBeforeEditor(new Text(` ${brand.error("✗ all fallback models failed:")} ${brand.error(cleanProviderError(reason))}`, 0, 0));
913
+ insertBeforeEditor(new Text(` ${brand.error("✗ all fallback models failed:")} ${brand.error(friendlyError(reason, cleanProviderError))}`, 0, 0));
866
914
  },
867
915
  });
868
916
  }
@@ -876,7 +924,7 @@ export async function runChat(opts) {
876
924
  // the `agent_end` event handler never runs. Re-assigning false when it's
877
925
  // already false is a no-op, so this is safe even when agent_end did fire.
878
926
  const raw = err instanceof Error ? err.message : String(err);
879
- const msg = cleanProviderError(raw);
927
+ const msg = friendlyError(raw, cleanProviderError);
880
928
  insertBeforeEditor(new Text(` ${brand.error("✗")} ${brand.error(msg)}`, 0, 0));
881
929
  isAgentRunning = false;
882
930
  editor.disableSubmit = false;
@@ -888,6 +936,37 @@ export async function runChat(opts) {
888
936
  // Likewise, the SIGINT handler is wired ONCE at the top level (index.ts) and
889
937
  // delegates here via the returned ChatHandle — preventing handler stacking.
890
938
  tui.requestRender();
939
+ // Optional first-run kickoff. Caller (post-onboard chat boot) passes
940
+ // `kickoffMessage` only when the workspace is truly fresh (BOOTSTRAP.md
941
+ // just seeded), so the agent reads BOOTSTRAP.md from its system prompt
942
+ // and opens the "who am I, who are you?" name-discovery conversation
943
+ // instead of sitting silently waiting for the user to type first.
944
+ //
945
+ // We dispatch through `editor.onSubmit` (the same surface a real Enter
946
+ // keypress hits) instead of calling `session.prompt` directly, so the
947
+ // turn flows through every wrapper (timeout, heartbeat, fallback chain,
948
+ // content-quality retry, …) just like a user-typed turn would. The
949
+ // kickoff message also renders as a normal "you" bubble in the
950
+ // conversation log, keeping the UI honest about what was sent.
951
+ //
952
+ // Fire-and-forget on a microtask so we return the ChatHandle promptly —
953
+ // the caller can wire SIGINT against the handle while the kickoff turn
954
+ // is still streaming.
955
+ const kickoff = opts.kickoffMessage?.trim();
956
+ if (kickoff && editor.onSubmit) {
957
+ const dispatchKickoff = editor.onSubmit;
958
+ queueMicrotask(() => {
959
+ void dispatchKickoff(kickoff);
960
+ });
961
+ }
962
+ // First-run discoverability tip. Only shown on a truly fresh workspace
963
+ // (same signal that gates the kickoff) so returning users don't see it
964
+ // every boot. The slash command list mirrors the editor footer hint,
965
+ // minus /help itself (we tell them how to find /help so they can read
966
+ // the full list).
967
+ if (opts.firstRun) {
968
+ insertBeforeEditor(new Text(` ${brand.dim("tip: type /help for slash commands (/model · /provider · /thinking · /compact)")}`, 0, 0));
969
+ }
891
970
  return {
892
971
  abort: () => {
893
972
  if (!isAgentRunning)