@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.
- package/CHANGELOG.md +48 -10
- package/LICENSE +21 -21
- package/README.md +8 -6
- package/dist/cli/chat-cmd.js +74 -3
- package/dist/cli/config-cmd.js +40 -1
- package/dist/cli/connect-cmd.js +42 -2
- package/dist/cli/doctor-cmd.js +85 -15
- package/dist/cli/gateway-cmd.js +69 -6
- package/dist/cli.js +53 -12
- package/dist/core/agent.js +3 -3
- package/dist/core/auth-error.js +111 -0
- package/dist/core/auth-label.js +147 -0
- package/dist/core/brigade-config.js +730 -0
- package/dist/core/cli-error.js +94 -0
- package/dist/core/config.js +182 -7
- package/dist/core/exec-approvals.js +337 -0
- package/dist/core/server.js +36 -0
- package/dist/core/system-prompt-defaults.js +221 -45
- package/dist/core/system-prompt-guidance.js +2 -0
- package/dist/core/system-prompt.js +842 -102
- package/dist/core/version.js +14 -0
- package/dist/index.js +8 -6
- package/dist/protocol.js +15 -0
- package/dist/ui/brand.js +31 -15
- package/dist/ui/chat.js +90 -11
- package/dist/ui/onboarding.js +218 -15
- package/dist/ui/terminal-cleanup.js +132 -0
- package/package.json +2 -3
- package/assets/brigade-wordmark-on-black.png +0 -0
|
@@ -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
|
-
|
|
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 +
|
|
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(
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
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
|
|
221
|
-
|
|
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
|
-
|
|
396
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (${
|
|
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(
|
|
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 =
|
|
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)
|