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.
- package/LICENSE +21 -0
- package/README.md +1 -1
- package/dist/agent/agent-loop.js +2 -5
- package/dist/agent/extensions/rolling-history/index.js +20 -8
- package/dist/agent/extensions/rolling-history/recall.d.ts +2 -2
- package/dist/agent/extensions/rolling-history/recall.js +17 -7
- package/dist/agent/providers/openai-compatible.d.ts +8 -0
- package/dist/agent/providers/openai-compatible.js +9 -2
- package/dist/agent/store.js +6 -1
- package/dist/agent/token-budget.d.ts +2 -1
- package/dist/agent/token-budget.js +6 -1
- package/dist/cli/index.js +1 -1
- package/dist/core/event-bus.d.ts +16 -1
- package/dist/core/event-bus.js +73 -11
- package/dist/core/index.js +18 -0
- package/dist/shell/tui-renderer.js +115 -174
- package/dist/utils/executor.js +19 -11
- package/dist/utils/floating-panel.d.ts +1 -0
- package/dist/utils/floating-panel.js +28 -26
- package/dist/utils/markdown.js +19 -21
- package/dist/utils/palette.d.ts +11 -0
- package/dist/utils/palette.js +11 -0
- package/docs/agent.md +13 -11
- package/docs/architecture.md +3 -5
- package/docs/extensions.md +21 -20
- package/docs/library.md +6 -3
- package/docs/troubleshooting.md +2 -2
- package/docs/tui-composition.md +11 -3
- package/docs/usage.md +70 -50
- package/examples/extensions/ashi/src/chat/assistant.ts +6 -4
- package/examples/extensions/ashi/src/compaction.ts +4 -7
- package/examples/extensions/ashi/src/frontend.ts +2 -0
- package/examples/extensions/ashi/src/schema.ts +8 -2
- package/examples/extensions/command-suggest.ts +4 -0
- package/examples/extensions/solarized-theme.ts +11 -0
- package/package.json +1 -1
- package/src/agent/agent-loop.ts +2 -5
- package/src/agent/extensions/rolling-history/index.ts +20 -8
- package/src/agent/extensions/rolling-history/recall.ts +28 -7
- package/src/agent/providers/openai-compatible.ts +19 -4
- package/src/agent/store.ts +5 -1
- package/src/agent/token-budget.ts +10 -1
- package/src/cli/index.ts +1 -1
- package/src/core/event-bus.ts +67 -12
- package/src/core/index.ts +18 -0
- package/src/shell/tui-renderer.ts +130 -207
- package/src/utils/executor.ts +17 -14
- package/src/utils/floating-panel.ts +24 -22
- package/src/utils/markdown.ts +17 -20
- 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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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).
|
|
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).
|
|
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
package/src/agent/agent-loop.ts
CHANGED
|
@@ -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 (
|
|
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: `
|
|
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
|
|
115
|
-
"Use when you need context from
|
|
116
|
-
"Search
|
|
117
|
-
"
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
110
|
-
|
|
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(
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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?:
|
|
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
|
}
|
package/src/agent/store.ts
CHANGED
|
@@ -54,7 +54,11 @@ function escapeRegex(s: string): string {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
function compileSearchRegex(query: string): RegExp {
|
|
57
|
-
|
|
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 =
|
|
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));
|
package/src/core/event-bus.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
133
|
+
try { fn(name, payload, meta); } catch (err) { this.fault("any", name, err); }
|
|
91
134
|
}
|
|
92
135
|
}
|
|
93
|
-
this.
|
|
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.
|
|
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.
|
|
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 {
|
|
177
|
+
try { fn(meta.name, payload, meta); } catch (err) { this.fault("any", meta.name, err); }
|
|
127
178
|
}
|
|
128
179
|
}
|
|
129
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|