agent-sh 0.15.6 → 0.15.8

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 (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1 -1
  3. package/dist/agent/agent-loop.d.ts +3 -0
  4. package/dist/agent/agent-loop.js +19 -6
  5. package/dist/agent/events.d.ts +3 -0
  6. package/dist/agent/extensions/rolling-history/index.js +20 -8
  7. package/dist/agent/extensions/rolling-history/recall.d.ts +2 -2
  8. package/dist/agent/extensions/rolling-history/recall.js +17 -7
  9. package/dist/agent/host-types.d.ts +6 -0
  10. package/dist/agent/index.js +5 -1
  11. package/dist/agent/llm-client.d.ts +2 -0
  12. package/dist/agent/llm-client.js +2 -2
  13. package/dist/agent/providers/openai-compatible.d.ts +8 -0
  14. package/dist/agent/providers/openai-compatible.js +9 -2
  15. package/dist/agent/providers/openrouter.js +11 -1
  16. package/dist/agent/store.js +6 -1
  17. package/dist/agent/token-budget.d.ts +2 -1
  18. package/dist/agent/token-budget.js +6 -1
  19. package/dist/cli/index.js +1 -1
  20. package/dist/core/event-bus.d.ts +16 -1
  21. package/dist/core/event-bus.js +73 -11
  22. package/dist/core/index.js +18 -0
  23. package/dist/shell/strategies/bash.js +10 -2
  24. package/dist/shell/tui-renderer.js +115 -174
  25. package/dist/utils/executor.js +19 -11
  26. package/dist/utils/floating-panel.d.ts +1 -0
  27. package/dist/utils/floating-panel.js +28 -26
  28. package/dist/utils/markdown.js +19 -21
  29. package/dist/utils/palette.d.ts +11 -0
  30. package/dist/utils/palette.js +11 -0
  31. package/docs/agent.md +13 -11
  32. package/docs/architecture.md +3 -5
  33. package/docs/extensions.md +21 -20
  34. package/docs/library.md +6 -3
  35. package/docs/troubleshooting.md +2 -2
  36. package/docs/tui-composition.md +11 -3
  37. package/docs/usage.md +70 -50
  38. package/examples/extensions/ashi/package.json +1 -1
  39. package/examples/extensions/ashi/src/chat/assistant.ts +8 -4
  40. package/examples/extensions/ashi/src/cli.ts +8 -0
  41. package/examples/extensions/ashi/src/compaction.ts +4 -7
  42. package/examples/extensions/ashi/src/frontend.ts +6 -3
  43. package/examples/extensions/ashi/src/renderers/pi-tui/inline-image.ts +145 -0
  44. package/examples/extensions/ashi/src/renderers/pi-tui/nodes.ts +51 -1
  45. package/examples/extensions/ashi/src/schema.ts +8 -2
  46. package/examples/extensions/ashi/src/user-shell-intents.ts +4 -1
  47. package/examples/extensions/command-suggest.ts +4 -0
  48. package/examples/extensions/latex-images.ts +152 -7
  49. package/examples/extensions/solarized-theme.ts +11 -0
  50. package/package.json +1 -1
  51. package/src/agent/agent-loop.ts +19 -6
  52. package/src/agent/events.ts +1 -0
  53. package/src/agent/extensions/rolling-history/index.ts +20 -8
  54. package/src/agent/extensions/rolling-history/recall.ts +28 -7
  55. package/src/agent/host-types.ts +2 -0
  56. package/src/agent/index.ts +7 -1
  57. package/src/agent/llm-client.ts +4 -2
  58. package/src/agent/providers/openai-compatible.ts +19 -4
  59. package/src/agent/providers/openrouter.ts +10 -1
  60. package/src/agent/store.ts +5 -1
  61. package/src/agent/token-budget.ts +10 -1
  62. package/src/cli/index.ts +1 -1
  63. package/src/core/event-bus.ts +67 -12
  64. package/src/core/index.ts +18 -0
  65. package/src/shell/strategies/bash.ts +10 -2
  66. package/src/shell/tui-renderer.ts +130 -207
  67. package/src/utils/executor.ts +17 -14
  68. package/src/utils/floating-panel.ts +24 -22
  69. package/src/utils/markdown.ts +17 -20
  70. package/src/utils/palette.ts +30 -5
@@ -462,6 +462,17 @@ export class AgentLoop implements AgentBackend {
462
462
  }
463
463
 
464
464
 
465
+ /** Resume-stable conversation id from the frontend (e.g. ashi); undefined
466
+ * when the frontend tracks no session. */
467
+ private currentSessionId(): string | undefined {
468
+ try {
469
+ const id = this.handlers.call("session:current-id");
470
+ return typeof id === "string" && id ? id : undefined;
471
+ } catch {
472
+ return undefined;
473
+ }
474
+ }
475
+
465
476
  private resolveEndpoint(m: Model): ModelEndpoint | undefined {
466
477
  try {
467
478
  return this.handlers.call("agent:resolve-endpoint", { provider: m.provider, id: m.id }) as ModelEndpoint | undefined;
@@ -971,12 +982,9 @@ export class AgentLoop implements AgentBackend {
971
982
  // tool-heavy workloads.
972
983
  const target = Math.floor(threshold * 0.25);
973
984
  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.
985
+ if (result) {
978
986
  this.bus.emit("ui:info", {
979
- message: `[auto-compact] above threshold (${totalEstimate.toLocaleString()} > ${threshold.toLocaleString()}) but nothing to evict — conversation may be too short`,
987
+ message: `(auto-compacted: ~${result.before.toLocaleString()} ~${result.after.toLocaleString()} tokens, evicted ${result.evictedCount})`,
980
988
  });
981
989
  }
982
990
  cachedSystemPrompt = undefined;
@@ -1442,7 +1450,12 @@ export class AgentLoop implements AgentBackend {
1442
1450
  };
1443
1451
  this.bus.emit("llm:request", requestParams);
1444
1452
 
1445
- const stream = await this.llmClient.stream({ ...requestParams, signal });
1453
+ const headers = this.activeEndpoint?.buildRequestHeaders?.({ sessionId: this.currentSessionId() });
1454
+ const stream = await this.llmClient.stream({
1455
+ ...requestParams,
1456
+ signal,
1457
+ ...(headers && Object.keys(headers).length ? { headers } : {}),
1458
+ });
1446
1459
 
1447
1460
  try {
1448
1461
  for await (const chunk of stream) {
@@ -22,6 +22,7 @@ declare module "../core/event-bus.js" {
22
22
  id: string;
23
23
  reasoningParams?: (level: string, model?: string) => Record<string, unknown>;
24
24
  cacheTokens?: (usage: Record<string, unknown>) => number | undefined;
25
+ requestHeaders?: (info: { sessionId?: string }) => Record<string, string>;
25
26
  };
26
27
 
27
28
  "agent:models-changed": Record<string, never>;
@@ -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
  }
@@ -93,6 +93,7 @@ export interface ModelEndpoint {
93
93
  baseURL?: string;
94
94
  buildReasoningParams?: (level: string) => Record<string, unknown>;
95
95
  extractCachedTokens?: (usage: Record<string, unknown>) => number | undefined;
96
+ buildRequestHeaders?: (info: { sessionId?: string }) => Record<string, string>;
96
97
  }
97
98
 
98
99
  // ── Agent-host extension surface ─────────────────────────────────
@@ -111,6 +112,7 @@ export interface AgentSurface {
111
112
  configure: (id: string, opts: {
112
113
  reasoningParams?: (level: string, model?: string) => Record<string, unknown>;
113
114
  cacheTokens?: (usage: Record<string, unknown>) => number | undefined;
115
+ requestHeaders?: (info: { sessionId?: string }) => Record<string, string>;
114
116
  }) => void;
115
117
  };
116
118
 
@@ -128,6 +128,7 @@ export default function agentBackend(ctx: ExtensionContext): void {
128
128
  const providerHooks = new Map<string, {
129
129
  reasoningParams?: (level: string, model?: string) => Record<string, unknown>;
130
130
  cacheTokens?: (usage: Record<string, unknown>) => number | undefined;
131
+ requestHeaders?: (info: { sessionId?: string }) => Record<string, string>;
131
132
  }>();
132
133
 
133
134
  // Bakes model id so ModelEndpoint.buildReasoningParams keeps its (level) signature.
@@ -139,6 +140,9 @@ export default function agentBackend(ctx: ExtensionContext): void {
139
140
  const bindCacheTokens = (shapeId: string) =>
140
141
  providerHooks.get(shapeId)?.cacheTokens ?? defaultCacheTokens;
141
142
 
143
+ const bindRequestHeaders = (shapeId: string) =>
144
+ providerHooks.get(shapeId)?.requestHeaders;
145
+
142
146
  const agentSurface: AgentSurface = {
143
147
  llm: createLlmFacade({ list: ctx.list, call: ctx.call }),
144
148
  providers: {
@@ -345,6 +349,7 @@ export default function agentBackend(ctx: ExtensionContext): void {
345
349
  baseURL: p.baseURL,
346
350
  buildReasoningParams: bindReasoning(shapeId, modelId),
347
351
  extractCachedTokens: bindCacheTokens(shapeId),
352
+ buildRequestHeaders: bindRequestHeaders(shapeId),
348
353
  };
349
354
  };
350
355
 
@@ -388,10 +393,11 @@ export default function agentBackend(ctx: ExtensionContext): void {
388
393
  }
389
394
  });
390
395
 
391
- bus.on("provider:configure", ({ id, reasoningParams, cacheTokens }) => {
396
+ bus.on("provider:configure", ({ id, reasoningParams, cacheTokens, requestHeaders }) => {
392
397
  const prev = providerHooks.get(id) ?? {};
393
398
  if (reasoningParams !== undefined) prev.reasoningParams = reasoningParams;
394
399
  if (cacheTokens !== undefined) prev.cacheTokens = cacheTokens;
400
+ if (requestHeaders !== undefined) prev.requestHeaders = requestHeaders;
395
401
  providerHooks.set(id, prev);
396
402
  });
397
403
 
@@ -68,7 +68,7 @@ export class LlmClient {
68
68
  }
69
69
 
70
70
  stream(opts: StreamOpts) {
71
- const { signal, messages, tools, model, max_tokens, ...rest } = opts;
71
+ const { signal, headers, messages, tools, model, max_tokens, ...rest } = opts;
72
72
  const body = {
73
73
  ...rest,
74
74
  model: model ?? this.model,
@@ -78,7 +78,7 @@ export class LlmClient {
78
78
  stream: true as const,
79
79
  stream_options: { include_usage: true },
80
80
  };
81
- return this.client.chat.completions.create(body as ChatCompletionCreateParamsStreaming, { signal });
81
+ return this.client.chat.completions.create(body as ChatCompletionCreateParamsStreaming, { signal, headers });
82
82
  }
83
83
 
84
84
  async complete(opts: CompleteOpts): Promise<string> {
@@ -102,6 +102,8 @@ export type StreamOpts = {
102
102
  model?: string;
103
103
  max_tokens?: number;
104
104
  signal?: AbortSignal;
105
+ /** Per-request transport headers, forwarded to the SDK (not request body). */
106
+ headers?: Record<string, string>;
105
107
  } & Record<string, unknown>;
106
108
 
107
109
  export type CompleteOpts = {
@@ -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
  }
@@ -42,7 +42,16 @@ function toModalities(input?: string[]): ("text" | "image")[] | undefined {
42
42
 
43
43
  export default function activate(ctx: AgentContext): void {
44
44
  const apiKey = resolveApiKey("openrouter").key;
45
- ctx.agent.providers.configure("openrouter", { reasoningParams: buildReasoningParams });
45
+ ctx.agent.providers.configure("openrouter", {
46
+ reasoningParams: buildReasoningParams,
47
+ // x-session-id pins sticky provider routing across turns so prompt caches
48
+ // stay warm even when compaction rewrites the opening messages.
49
+ requestHeaders: ({ sessionId }) => {
50
+ const headers: Record<string, string> = {};
51
+ if (sessionId) headers["x-session-id"] = sessionId;
52
+ return headers;
53
+ },
54
+ });
46
55
  ctx.agent.providers.register({
47
56
  id: "openrouter",
48
57
  apiKey: apiKey ?? undefined,
@@ -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
 
@@ -47,8 +47,16 @@ export const bashStrategy: ShellStrategy = {
47
47
  ' [[ $__agent_sh_preexec_ran == 1 ]] && return',
48
48
  ' [[ -n $COMP_LINE ]] && return',
49
49
  " __agent_sh_preexec_ran=1",
50
- " local this_cmd",
51
- ` this_cmd=$(HISTTIMEFORMAT='' builtin history 1 | command sed 's/^ *[0-9]* *//')`,
50
+ " local this_cmd hist_cmd",
51
+ ` hist_cmd=$(HISTTIMEFORMAT='' builtin history 1 | command sed 's/^ *[0-9]* *//')`,
52
+ " # history 1 carries the full typed line but goes stale when the user's",
53
+ " # PROMPT_COMMAND reloads history (history -c/-r). Trust it only when it",
54
+ " # matches the command bash is about to run; else use $BASH_COMMAND.",
55
+ ' if [[ -n $hist_cmd && $hist_cmd == "$BASH_COMMAND"* ]]; then',
56
+ " this_cmd=$hist_cmd",
57
+ " else",
58
+ " this_cmd=$BASH_COMMAND",
59
+ " fi",
52
60
  ` printf '\\e]9997;${instanceTag};%s\\a' "$this_cmd"`,
53
61
  "}",
54
62
  "trap '__agent_sh_emit_preexec' DEBUG",