agent-sh 0.15.6 → 0.15.7

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 (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1 -1
  3. package/dist/agent/agent-loop.js +2 -5
  4. package/dist/agent/extensions/rolling-history/index.js +20 -8
  5. package/dist/agent/extensions/rolling-history/recall.d.ts +2 -2
  6. package/dist/agent/extensions/rolling-history/recall.js +17 -7
  7. package/dist/agent/providers/openai-compatible.d.ts +8 -0
  8. package/dist/agent/providers/openai-compatible.js +9 -2
  9. package/dist/agent/store.js +6 -1
  10. package/dist/agent/token-budget.d.ts +2 -1
  11. package/dist/agent/token-budget.js +6 -1
  12. package/dist/cli/index.js +1 -1
  13. package/dist/core/event-bus.d.ts +16 -1
  14. package/dist/core/event-bus.js +73 -11
  15. package/dist/core/index.js +18 -0
  16. package/dist/shell/tui-renderer.js +115 -174
  17. package/dist/utils/executor.js +19 -11
  18. package/dist/utils/floating-panel.d.ts +1 -0
  19. package/dist/utils/floating-panel.js +28 -26
  20. package/dist/utils/markdown.js +19 -21
  21. package/dist/utils/palette.d.ts +11 -0
  22. package/dist/utils/palette.js +11 -0
  23. package/docs/agent.md +13 -11
  24. package/docs/architecture.md +3 -5
  25. package/docs/extensions.md +21 -20
  26. package/docs/library.md +6 -3
  27. package/docs/troubleshooting.md +2 -2
  28. package/docs/tui-composition.md +11 -3
  29. package/docs/usage.md +70 -50
  30. package/examples/extensions/ashi/src/chat/assistant.ts +6 -4
  31. package/examples/extensions/ashi/src/compaction.ts +4 -7
  32. package/examples/extensions/ashi/src/frontend.ts +2 -0
  33. package/examples/extensions/ashi/src/schema.ts +8 -2
  34. package/examples/extensions/command-suggest.ts +4 -0
  35. package/examples/extensions/solarized-theme.ts +11 -0
  36. package/package.json +1 -1
  37. package/src/agent/agent-loop.ts +2 -5
  38. package/src/agent/extensions/rolling-history/index.ts +20 -8
  39. package/src/agent/extensions/rolling-history/recall.ts +28 -7
  40. package/src/agent/providers/openai-compatible.ts +19 -4
  41. package/src/agent/store.ts +5 -1
  42. package/src/agent/token-budget.ts +10 -1
  43. package/src/cli/index.ts +1 -1
  44. package/src/core/event-bus.ts +67 -12
  45. package/src/core/index.ts +18 -0
  46. package/src/shell/tui-renderer.ts +130 -207
  47. package/src/utils/executor.ts +17 -14
  48. package/src/utils/floating-panel.ts +24 -22
  49. package/src/utils/markdown.ts +17 -20
  50. package/src/utils/palette.ts +30 -5
@@ -39,19 +39,16 @@ export function registerCompaction(
39
39
  }
40
40
 
41
41
  const older = messages.slice(0, cutIdx);
42
- const kept = messages.slice(cutIdx);
43
42
  const tokensBefore = (ctx.call("conversation:estimate-prompt-tokens") as number) ?? 0;
44
43
  const customSummary = (await ctx.call("ashi:compact:build-summary", older)) as string | null | undefined;
45
44
 
46
45
  const store = getStore().current();
47
46
  await store.appendCompaction(firstKeptId, tokensBefore, customSummary ?? undefined);
48
- ctx.call("conversation:replace-messages", store.buildMessages());
49
47
 
50
- const keptIds = kept.map((_, i) => capture.getEntryIdAt(cutIdx + i));
51
- if (keptIds.some((id) => id === null)) {
52
- ctx.bus.emit("ui:error", { message: "compaction: a kept message has no on-disk entry — capture invariant broken" });
53
- }
54
- capture.resetTo([null, ...keptIds]);
48
+ // Take messages and ids from one rebuild so capture's index→id map can't drift.
49
+ const { messages: rebuilt, entryIds } = store.buildBranchWithIds();
50
+ ctx.call("conversation:replace-messages", rebuilt);
51
+ capture.resetTo(entryIds);
55
52
 
56
53
  const tokensAfter = (ctx.call("conversation:estimate-prompt-tokens") as number) ?? 0;
57
54
  return { before: tokensBefore, after: tokensAfter, evictedCount: older.length };
@@ -650,6 +650,8 @@ export function mountAshi(
650
650
  activeThinking = null;
651
651
  activeTools.clear();
652
652
  openGroup = null;
653
+ compactions = 0;
654
+ statusFooter.update({ compactions });
653
655
  clearChat();
654
656
  const branch = getStore().current().getBranch();
655
657
  const toolMap = new Map<string, ReplayEntry>();
@@ -1,4 +1,5 @@
1
1
  import { theme } from "./theme.js";
2
+ import { truncateToWidth } from "@earendil-works/pi-tui";
2
3
  import { highlight, supportsLanguage } from "cli-highlight";
3
4
  import type { ThemeColor } from "./theme.js";
4
5
  import type { ToolEntryConfig } from "./display-config.js";
@@ -188,6 +189,11 @@ export function renderBody(body: Body, env: Env, diff: DiffSlot): string {
188
189
  // The tail is capped even when expanded so a huge result can't flood scrollback; the agent still sees it all.
189
190
  const DEFAULT_EXPANDED_LINES = 200;
190
191
 
192
+ function clampLines(lines: string[], width: number): string {
193
+ if (width <= 0) return lines.join("\n");
194
+ return lines.map((l) => truncateToWidth(l, width, "…")).join("\n");
195
+ }
196
+
191
197
  function renderStream(buffer: string, env: Env): string {
192
198
  const display = buffer.replace(/\n+$/, "");
193
199
  if (env.expanded) {
@@ -205,14 +211,14 @@ function renderStream(buffer: string, env: Env): string {
205
211
  }
206
212
  if (env.mode === "summary") {
207
213
  if (!env.finalized) {
208
- const tail = display.split("\n").slice(-2).join("\n");
214
+ const tail = clampLines(display.split("\n").slice(-2), env.width);
209
215
  return theme.fg("muted", tail);
210
216
  }
211
217
  return lineCountHint(buffer);
212
218
  }
213
219
  if (!display) return "";
214
220
  const lines = display.split("\n");
215
- const trimmed = lines.slice(-env.previewLines).join("\n");
221
+ const trimmed = clampLines(lines.slice(-env.previewLines), env.width);
216
222
  const remaining = Math.max(0, lines.length - env.previewLines);
217
223
  // The preview is the tail, so the hidden lines come before it — note goes above.
218
224
  const overflow = remaining > 0
@@ -15,6 +15,10 @@ import type { ExtensionContext } from "agent-sh/types";
15
15
 
16
16
  export default function activate(ctx: ExtensionContext): void {
17
17
  const { bus } = ctx;
18
+
19
+ // No shell to deliver to (e.g. ashi) — the suggestion would go nowhere.
20
+ if (!ctx.shell) return;
21
+
18
22
  let pendingCommand: string | null = null;
19
23
 
20
24
  // ── Tool ────────────────────────────────────────────────────────
@@ -23,5 +23,16 @@ export default function activate(ctx: ShellContext) {
23
23
  errorBg: "\x1b[48;2;42;30;30m", // base03 with red tint
24
24
  successBgEmph: "\x1b[48;2;20;70;50m", // stronger green tint
25
25
  errorBgEmph: "\x1b[48;2;70;30;30m", // stronger red tint
26
+
27
+ mdHeading: "\x1b[38;2;181;137;0m", // yellow (#b58900)
28
+ mdLink: "\x1b[38;2;38;139;210m", // blue (#268bd2)
29
+ mdLinkUrl: "\x1b[38;2;88;110;117m", // base01 (#586e75)
30
+ mdCode: "\x1b[38;2;42;161;152m", // cyan (#2aa198)
31
+ mdCodeBlock: "\x1b[38;2;133;153;0m", // green (#859900)
32
+ mdCodeBlockBorder: "\x1b[38;2;88;110;117m", // base01 (#586e75)
33
+ mdQuote: "\x1b[38;2;88;110;117m", // base01 (#586e75)
34
+ mdQuoteBorder: "\x1b[38;2;88;110;117m", // base01 (#586e75)
35
+ mdHr: "\x1b[38;2;88;110;117m", // base01 (#586e75)
36
+ mdListBullet: "\x1b[38;2;38;139;210m", // blue (#268bd2)
26
37
  });
27
38
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sh",
3
- "version": "0.15.6",
3
+ "version": "0.15.7",
4
4
  "description": "A composable agent runtime — pair any frontend with any agent backend over one shared extension layer",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -971,12 +971,9 @@ export class AgentLoop implements AgentBackend {
971
971
  // tool-heavy workloads.
972
972
  const target = Math.floor(threshold * 0.25);
973
973
  const result = await this.compactWithHooks(target, 1);
974
- if (!result) {
975
- // Auto-compact fired but nothing was evictable. This can happen
976
- // in short conversations with heavy tool output where the pin
977
- // fraction consumes all turns. Log it so it's not silent.
974
+ if (result) {
978
975
  this.bus.emit("ui:info", {
979
- message: `[auto-compact] above threshold (${totalEstimate.toLocaleString()} > ${threshold.toLocaleString()}) but nothing to evict — conversation may be too short`,
976
+ message: `(auto-compacted: ~${result.before.toLocaleString()} ~${result.after.toLocaleString()} tokens, evicted ${result.evictedCount})`,
980
977
  });
981
978
  }
982
979
  cachedSystemPrompt = undefined;
@@ -111,32 +111,44 @@ export default function activate(ctx: ExtensionContext): void {
111
111
  name: TOOL_NAME,
112
112
  displayName: "recall",
113
113
  description:
114
- "Browse, search, or expand evicted conversation turns. " +
115
- "Use when you need context from earlier in the conversation that was compacted away. " +
116
- "Search is regex-based and covers both summaries and full body text. " +
117
- "If search doesn't find what you expect, try broader/shorter terms or browse to scan the timeline.",
114
+ "Browse, search, or expand the persistent conversation memory — all captured turns across this and recent sessions. " +
115
+ "Use when you need context from prior turns or past sessions that may no longer be in the active window. " +
116
+ "Search accepts a regex pattern (e.g. 'foo|bar') and falls back to literal matching if the pattern is invalid. " +
117
+ "Covers both summaries and full body text. " +
118
+ "If search doesn't find what you expect, try broader/shorter terms or browse to scan the timeline. " +
119
+ "Use offset for pagination on both browse and search.",
118
120
  input_schema: {
119
121
  type: "object",
120
122
  properties: {
121
123
  action: {
122
124
  type: "string",
123
125
  enum: ["browse", "search", "expand"],
124
- description: "browse: list evicted turns, search: regex search, expand: show full turn",
126
+ description: "browse: list recent captured turns, search: regex search across memory, expand: show full turn body",
125
127
  },
126
- query: { type: "string", description: "Search query (for action=search)" },
128
+ query: { type: "string", description: "Search pattern — a regex (e.g. 'foo|bar') or literal text (for action=search)" },
127
129
  turn_id: { type: "string", description: "Turn ID to expand (for action=expand)" },
130
+ offset: {
131
+ type: "number",
132
+ description: "Skip first N results; for browse, start at this entry offset; for search, skip first N hits. Default 0.",
133
+ },
134
+ limit: {
135
+ type: "number",
136
+ description: "Max entries to return for browse (default 25) or search (default 30).",
137
+ },
128
138
  },
129
139
  required: ["action"],
130
140
  },
131
141
  execute: async (args) => {
132
142
  const action = args.action as string;
143
+ const offset = (args.offset as number) ?? 0;
144
+ const limit = (args.limit as number) ?? (action === "search" ? 30 : 25);
133
145
  let content: string;
134
146
  if (action === "search") {
135
- content = await recallSearch(summaryStore, (args.query as string) ?? "");
147
+ content = await recallSearch(summaryStore, (args.query as string) ?? "", offset, limit);
136
148
  } else if (action === "expand") {
137
149
  content = await recallExpand(summaryStore, args.turn_id as string);
138
150
  } else {
139
- content = await recallBrowse(summaryStore);
151
+ content = await recallBrowse(summaryStore, offset, limit);
140
152
  }
141
153
  return { content, exitCode: 0, isError: false };
142
154
  },
@@ -76,7 +76,12 @@ async function findCacheChild(store: Store, parentId: string): Promise<RecallCac
76
76
  return null;
77
77
  }
78
78
 
79
- export async function recallSearch(store: Store, query: string): Promise<string> {
79
+ export async function recallSearch(
80
+ store: Store,
81
+ query: string,
82
+ offset = 0,
83
+ maxResults = 30,
84
+ ): Promise<string> {
80
85
  if (!query.trim()) return "No query provided.";
81
86
  const regex = buildSearchRegex(query);
82
87
  const hits: string[] = [];
@@ -106,8 +111,13 @@ export async function recallSearch(store: Store, query: string): Promise<string>
106
111
 
107
112
  if (hits.length === 0) return `No results found for "${query}".`;
108
113
  const total = hits.length;
109
- const summary = `Found ${total} match${total === 1 ? "" : "es"} for "${query}"`;
110
- return `${summary}\n\n${hits.slice(0, 30).join("\n\n")}`;
114
+ const paged = hits.slice(offset, offset + maxResults);
115
+ const range =
116
+ offset > 0 || paged.length < total
117
+ ? ` (showing ${offset + 1}–${offset + paged.length} of ${total})`
118
+ : "";
119
+ const summary = `Found ${total} match${total === 1 ? "" : "es"} for "${query}"${range}`;
120
+ return `${summary}\n\n${paged.join("\n\n")}`;
111
121
  }
112
122
 
113
123
  export async function recallExpand(store: Store, id: string): Promise<string> {
@@ -124,8 +134,19 @@ export async function recallExpand(store: Store, id: string): Promise<string> {
124
134
  return `${header}\n\n(no expanded content available — recall cache may have been cleared)`;
125
135
  }
126
136
 
127
- export async function recallBrowse(store: Store, limit = 25): Promise<string> {
128
- const lines = await readSummaryLines(store, limit);
129
- if (lines.length === 0) return "No conversation history.";
130
- return ["Recent summary entries:", ...lines.map((l) => ` ${l}`)].join("\n");
137
+ export async function recallBrowse(
138
+ store: Store,
139
+ offset = 0,
140
+ limit = 25,
141
+ ): Promise<string> {
142
+ const overRead = Math.max(limit * 3, offset + limit);
143
+ const allLines = await readSummaryLines(store, overRead);
144
+ if (allLines.length === 0) return "No conversation history.";
145
+ const end = Math.min(offset + limit, allLines.length);
146
+ const paged = allLines.slice(offset, end);
147
+ const range =
148
+ offset > 0 || end < allLines.length
149
+ ? ` (entries ${offset + 1}–${end} of ${allLines.length} shown)`
150
+ : "";
151
+ return [`Recent summary entries${range}:`, ...paged.map((l) => ` ${l}`)].join("\n");
131
152
  }
@@ -28,17 +28,32 @@ export default function activate(ctx: AgentContext): void {
28
28
  id,
29
29
  apiKey,
30
30
  baseURL,
31
- defaultModel: models[0],
31
+ defaultModel: models[0]!.id,
32
32
  models,
33
33
  });
34
34
  }).catch(() => { /* leave empty — user supplies via --model */ });
35
35
  }
36
36
 
37
- async function fetchModels(baseURL: string, apiKey: string): Promise<string[]> {
37
+ export interface CatalogModel {
38
+ id: string;
39
+ meta?: { n_ctx?: number };
40
+ max_model_len?: number;
41
+ }
42
+
43
+ export function catalogContextWindow(m: CatalogModel): number | undefined {
44
+ if (typeof m.meta?.n_ctx === "number" && m.meta.n_ctx > 0) return m.meta.n_ctx;
45
+ if (typeof m.max_model_len === "number" && m.max_model_len > 0) return m.max_model_len;
46
+ return undefined;
47
+ }
48
+
49
+ async function fetchModels(
50
+ baseURL: string,
51
+ apiKey: string,
52
+ ): Promise<{ id: string; contextWindow?: number }[]> {
38
53
  const headers: Record<string, string> = {};
39
54
  if (apiKey && apiKey !== "no-key") headers.Authorization = `Bearer ${apiKey}`;
40
55
  const res = await fetch(`${baseURL.replace(/\/$/, "")}/models`, { headers });
41
56
  if (!res.ok) return [];
42
- const data = await res.json() as { data?: { id: string }[] };
43
- return (data.data ?? []).map((m) => m.id);
57
+ const data = await res.json() as { data?: CatalogModel[] };
58
+ return (data.data ?? []).map((m) => ({ id: m.id, contextWindow: catalogContextWindow(m) }));
44
59
  }
@@ -54,7 +54,11 @@ function escapeRegex(s: string): string {
54
54
  }
55
55
 
56
56
  function compileSearchRegex(query: string): RegExp {
57
- return new RegExp(escapeRegex(query), "i");
57
+ try {
58
+ return new RegExp(query, "i");
59
+ } catch {
60
+ return new RegExp(escapeRegex(query), "i");
61
+ }
58
62
  }
59
63
 
60
64
  function matchEntry(entry: Entry, re: RegExp): SearchHit | null {
@@ -8,5 +8,14 @@
8
8
  /** Response reserve — tokens reserved for the model's output. */
9
9
  export const RESPONSE_RESERVE = 8192;
10
10
 
11
+ const FALLBACK_CONTEXT_WINDOW = 60_000;
12
+
13
+ export function resolveDefaultContextWindow(
14
+ env: Record<string, string | undefined> = process.env,
15
+ ): number {
16
+ const n = Number(env.AGENT_SH_DEFAULT_CONTEXT_WINDOW);
17
+ return Number.isInteger(n) && n > 0 ? n : FALLBACK_CONTEXT_WINDOW;
18
+ }
19
+
11
20
  /** Fallback when contextWindow is unknown. */
12
- export const DEFAULT_CONTEXT_WINDOW = 60_000;
21
+ export const DEFAULT_CONTEXT_WINDOW = resolveDefaultContextWindow();
package/src/cli/index.ts CHANGED
@@ -128,7 +128,6 @@ async function main(): Promise<void> {
128
128
  // Load before spawning the shell so PS1 lands below the banner.
129
129
  const settings = getSettings();
130
130
  await loadBuiltinExtensions(extCtx, settings.disabledBuiltins);
131
- activateRollingHistory(extCtx);
132
131
  const loadExtensionsTimeoutMs = 10000;
133
132
  let loadedExtensions: string[] = [];
134
133
  await Promise.race([
@@ -197,6 +196,7 @@ async function main(): Promise<void> {
197
196
  }
198
197
 
199
198
  await core.activateBackend(config.backend);
199
+ activateRollingHistory(extCtx);
200
200
 
201
201
  // 100ms sidesteps macOS SIGTTOU during fg-pgrp handoff.
202
202
  await new Promise(resolve => setTimeout(resolve, 100));
@@ -1,5 +1,3 @@
1
- import { EventEmitter } from "node:events";
2
-
3
1
  export interface BackendRegistration {
4
2
  name: string;
5
3
  kill: () => void;
@@ -49,6 +47,15 @@ export interface BusMeta {
49
47
 
50
48
  export type AnyListener = (name: string, payload: unknown, meta: BusMeta) => void;
51
49
 
50
+ /** A listener fault routed to the error reporter; `phase` is the callback site. */
51
+ export interface BusFault {
52
+ phase: "on" | "any" | "pipe" | "pipe-async";
53
+ event: string;
54
+ err: unknown;
55
+ }
56
+
57
+ export type ErrorReporter = (fault: BusFault) => void;
58
+
52
59
  /**
53
60
  * Typed event bus with two modes:
54
61
  * - emit/on/off: fire-and-forget notifications
@@ -56,18 +63,54 @@ export type AnyListener = (name: string, payload: unknown, meta: BusMeta) => voi
56
63
  * can modify the payload before passing to the next
57
64
  */
58
65
  export class EventBus {
59
- private emitter = new EventEmitter().setMaxListeners(0);
66
+ private listeners = new Map<string, Listener<any>[]>();
60
67
  private pipeListeners = new Map<string, PipeListener<any>[]>();
61
68
  private asyncPipeListeners = new Map<string, AsyncPipeListener<any>[]>();
62
69
  private source = "0000";
63
70
  private nextSeq = 0;
64
71
  private anyListeners: AnyListener[] = [];
65
72
 
73
+ /** Default fault sink, overridable via setErrorReporter: silent unless DEBUG. */
74
+ private reportError: ErrorReporter = ({ phase, event, err }) => {
75
+ if (process.env.DEBUG) {
76
+ const msg = err instanceof Error ? (err.stack ?? err.message) : String(err);
77
+ process.stderr.write(`[event-bus] ${phase} fault on "${event}": ${msg}\n`);
78
+ }
79
+ };
80
+
66
81
  /** Set the source id stamped onto every emitted event. */
67
82
  setSource(src: string): void {
68
83
  this.source = src;
69
84
  }
70
85
 
86
+ /** Install a fault reporter. */
87
+ setErrorReporter(fn: ErrorReporter): void {
88
+ this.reportError = fn;
89
+ }
90
+
91
+ /** Report a fault; guarded so a broken reporter can't break dispatch. */
92
+ private fault(phase: BusFault["phase"], event: string, err: unknown): void {
93
+ try {
94
+ this.reportError({ phase, event, err });
95
+ } catch {
96
+ /* swallow */
97
+ }
98
+ }
99
+
100
+ /** Fire every listener for `name`, isolating faults. */
101
+ private notify(name: string, payload: unknown): void {
102
+ const arr = this.listeners.get(name);
103
+ if (!arr || arr.length === 0) return;
104
+ // snapshot so a listener that (un)subscribes mid-dispatch can't shift iteration
105
+ if (arr.length === 1) {
106
+ try { arr[0](payload); } catch (err) { this.fault("on", name, err); }
107
+ return;
108
+ }
109
+ for (const fn of arr.slice()) {
110
+ try { fn(payload); } catch (err) { this.fault("on", name, err); }
111
+ }
112
+ }
113
+
71
114
  /** Subscribe to every emitted event with full envelope. Returns unsubscribe. */
72
115
  onAny(fn: AnyListener): () => void {
73
116
  this.anyListeners.push(fn);
@@ -87,10 +130,10 @@ export class EventBus {
87
130
  name,
88
131
  };
89
132
  for (const fn of this.anyListeners) {
90
- try { fn(name, payload, meta); } catch { /* swallow */ }
133
+ try { fn(name, payload, meta); } catch (err) { this.fault("any", name, err); }
91
134
  }
92
135
  }
93
- this.emitter.emit(name, payload);
136
+ this.notify(name, payload);
94
137
  }
95
138
 
96
139
  /** Subscribe to a fire-and-forget event. */
@@ -98,7 +141,12 @@ export class EventBus {
98
141
  event: K,
99
142
  fn: Listener<BusEvents[K]>,
100
143
  ): void {
101
- this.emitter.on(event, fn);
144
+ let arr = this.listeners.get(event);
145
+ if (!arr) {
146
+ arr = [];
147
+ this.listeners.set(event, arr);
148
+ }
149
+ arr.push(fn);
102
150
  }
103
151
 
104
152
  /** Unsubscribe from a fire-and-forget event. */
@@ -106,7 +154,10 @@ export class EventBus {
106
154
  event: K,
107
155
  fn: Listener<BusEvents[K]>,
108
156
  ): void {
109
- this.emitter.off(event, fn);
157
+ const arr = this.listeners.get(event);
158
+ if (!arr) return;
159
+ const idx = arr.indexOf(fn);
160
+ if (idx !== -1) arr.splice(idx, 1);
110
161
  }
111
162
 
112
163
  /** Emit a fire-and-forget event. */
@@ -123,10 +174,10 @@ export class EventBus {
123
174
  relay(meta: BusMeta, payload: unknown): void {
124
175
  if (this.anyListeners.length > 0) {
125
176
  for (const fn of this.anyListeners) {
126
- try { fn(meta.name, payload, meta); } catch { /* swallow */ }
177
+ try { fn(meta.name, payload, meta); } catch (err) { this.fault("any", meta.name, err); }
127
178
  }
128
179
  }
129
- this.emitter.emit(meta.name, payload);
180
+ this.notify(meta.name, payload);
130
181
  }
131
182
 
132
183
  /**
@@ -191,12 +242,12 @@ export class EventBus {
191
242
  try {
192
243
  const out = fn(result);
193
244
  if (out && typeof (out as any).then === "function") {
194
- console.error(`[event-bus] Warning: async handler in sync pipe "${String(event)}" — use onPipeAsync instead`);
245
+ this.fault("pipe", String(event), new Error("async handler in sync pipe — use onPipeAsync instead"));
195
246
  continue;
196
247
  }
197
248
  result = out;
198
249
  } catch (err) {
199
- console.error(`[event-bus] Pipe handler error in "${String(event)}":`, err instanceof Error ? err.message : err);
250
+ this.fault("pipe", String(event), err);
200
251
  }
201
252
  }
202
253
  return result;
@@ -245,7 +296,11 @@ export class EventBus {
245
296
  if (!listeners) return payload;
246
297
  let result = payload;
247
298
  for (const fn of listeners) {
248
- result = await fn(result);
299
+ try {
300
+ result = await fn(result);
301
+ } catch (err) {
302
+ this.fault("pipe-async", String(event), err);
303
+ }
249
304
  }
250
305
  return result;
251
306
  }
package/src/core/index.ts CHANGED
@@ -48,6 +48,24 @@ export function createCore(config: AppConfig): AgentShellCore {
48
48
  // should accept ≥6 hex chars.
49
49
  const instanceId = crypto.randomBytes(3).toString("hex");
50
50
  bus.setSource(instanceId);
51
+
52
+ // Surface faults on ui:error; `surfacing` stops a faulting renderer from looping.
53
+ let surfacing = false;
54
+ bus.setErrorReporter(({ phase, event, err }) => {
55
+ const detail = err instanceof Error ? err.message : String(err);
56
+ if (process.env.DEBUG) {
57
+ const full = err instanceof Error ? (err.stack ?? err.message) : detail;
58
+ process.stderr.write(`[event-bus] ${phase} fault on "${event}": ${full}\n`);
59
+ }
60
+ if (surfacing) return;
61
+ surfacing = true;
62
+ try {
63
+ bus.emit("ui:error", { message: `Handler error on "${event}": ${detail}` });
64
+ } finally {
65
+ surfacing = false;
66
+ }
67
+ });
68
+
51
69
  handlers.define("config:get-app-config", () => config);
52
70
  handlers.define("cwd", () => process.cwd());
53
71