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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yilun Guan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -59,7 +59,7 @@ Once installed, pick a backend below.
|
|
|
59
59
|
|
|
60
60
|
### Option A: Use the built-in agent (ash) — recommended
|
|
61
61
|
|
|
62
|
-
`ash` is agent-sh's own lightweight agent, and the path most users should start with: it shares its tool surface with the rest of the system, so extensions you install (new tools, content transforms, slash commands, themes) compose with it directly. It works with any OpenAI-compatible API — pick one of the zero-config paths below, no settings file needed. The built-in providers (openrouter, openai, openai-compatible
|
|
62
|
+
`ash` is agent-sh's own lightweight agent, and the path most users should start with: it shares its tool surface with the rest of the system, so extensions you install (new tools, content transforms, slash commands, themes) compose with it directly. It works with any OpenAI-compatible API — pick one of the zero-config paths below, no settings file needed. The built-in providers (openrouter, openai, deepseek, ollama, zai-coding-plan, opencode — plus openai-compatible when `OPENAI_BASE_URL` is set) register on startup; ash activates the first one with a usable key.
|
|
63
63
|
|
|
64
64
|
**Quickest path** — store a key once via the auth subcommand:
|
|
65
65
|
|
package/dist/agent/agent-loop.js
CHANGED
|
@@ -820,12 +820,9 @@ export class AgentLoop {
|
|
|
820
820
|
// tool-heavy workloads.
|
|
821
821
|
const target = Math.floor(threshold * 0.25);
|
|
822
822
|
const result = await this.compactWithHooks(target, 1);
|
|
823
|
-
if (
|
|
824
|
-
// Auto-compact fired but nothing was evictable. This can happen
|
|
825
|
-
// in short conversations with heavy tool output where the pin
|
|
826
|
-
// fraction consumes all turns. Log it so it's not silent.
|
|
823
|
+
if (result) {
|
|
827
824
|
this.bus.emit("ui:info", {
|
|
828
|
-
message: `
|
|
825
|
+
message: `(auto-compacted: ~${result.before.toLocaleString()} → ~${result.after.toLocaleString()} tokens, evicted ${result.evictedCount})`,
|
|
829
826
|
});
|
|
830
827
|
}
|
|
831
828
|
cachedSystemPrompt = undefined;
|
|
@@ -102,34 +102,46 @@ export default function activate(ctx) {
|
|
|
102
102
|
const toolDef = {
|
|
103
103
|
name: TOOL_NAME,
|
|
104
104
|
displayName: "recall",
|
|
105
|
-
description: "Browse, search, or expand
|
|
106
|
-
"Use when you need context from
|
|
107
|
-
"Search
|
|
108
|
-
"
|
|
105
|
+
description: "Browse, search, or expand the persistent conversation memory — all captured turns across this and recent sessions. " +
|
|
106
|
+
"Use when you need context from prior turns or past sessions that may no longer be in the active window. " +
|
|
107
|
+
"Search accepts a regex pattern (e.g. 'foo|bar') and falls back to literal matching if the pattern is invalid. " +
|
|
108
|
+
"Covers both summaries and full body text. " +
|
|
109
|
+
"If search doesn't find what you expect, try broader/shorter terms or browse to scan the timeline. " +
|
|
110
|
+
"Use offset for pagination on both browse and search.",
|
|
109
111
|
input_schema: {
|
|
110
112
|
type: "object",
|
|
111
113
|
properties: {
|
|
112
114
|
action: {
|
|
113
115
|
type: "string",
|
|
114
116
|
enum: ["browse", "search", "expand"],
|
|
115
|
-
description: "browse: list
|
|
117
|
+
description: "browse: list recent captured turns, search: regex search across memory, expand: show full turn body",
|
|
116
118
|
},
|
|
117
|
-
query: { type: "string", description: "Search
|
|
119
|
+
query: { type: "string", description: "Search pattern — a regex (e.g. 'foo|bar') or literal text (for action=search)" },
|
|
118
120
|
turn_id: { type: "string", description: "Turn ID to expand (for action=expand)" },
|
|
121
|
+
offset: {
|
|
122
|
+
type: "number",
|
|
123
|
+
description: "Skip first N results; for browse, start at this entry offset; for search, skip first N hits. Default 0.",
|
|
124
|
+
},
|
|
125
|
+
limit: {
|
|
126
|
+
type: "number",
|
|
127
|
+
description: "Max entries to return for browse (default 25) or search (default 30).",
|
|
128
|
+
},
|
|
119
129
|
},
|
|
120
130
|
required: ["action"],
|
|
121
131
|
},
|
|
122
132
|
execute: async (args) => {
|
|
123
133
|
const action = args.action;
|
|
134
|
+
const offset = args.offset ?? 0;
|
|
135
|
+
const limit = args.limit ?? (action === "search" ? 30 : 25);
|
|
124
136
|
let content;
|
|
125
137
|
if (action === "search") {
|
|
126
|
-
content = await recallSearch(summaryStore, args.query ?? "");
|
|
138
|
+
content = await recallSearch(summaryStore, args.query ?? "", offset, limit);
|
|
127
139
|
}
|
|
128
140
|
else if (action === "expand") {
|
|
129
141
|
content = await recallExpand(summaryStore, args.turn_id);
|
|
130
142
|
}
|
|
131
143
|
else {
|
|
132
|
-
content = await recallBrowse(summaryStore);
|
|
144
|
+
content = await recallBrowse(summaryStore, offset, limit);
|
|
133
145
|
}
|
|
134
146
|
return { content, exitCode: 0, isError: false };
|
|
135
147
|
},
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import type { Store } from "../../store.js";
|
|
2
|
-
export declare function recallSearch(store: Store, query: string): Promise<string>;
|
|
2
|
+
export declare function recallSearch(store: Store, query: string, offset?: number, maxResults?: number): Promise<string>;
|
|
3
3
|
export declare function recallExpand(store: Store, id: string): Promise<string>;
|
|
4
|
-
export declare function recallBrowse(store: Store, limit?: number): Promise<string>;
|
|
4
|
+
export declare function recallBrowse(store: Store, offset?: number, limit?: number): Promise<string>;
|
|
@@ -64,7 +64,7 @@ async function findCacheChild(store, parentId) {
|
|
|
64
64
|
}
|
|
65
65
|
return null;
|
|
66
66
|
}
|
|
67
|
-
export async function recallSearch(store, query) {
|
|
67
|
+
export async function recallSearch(store, query, offset = 0, maxResults = 30) {
|
|
68
68
|
if (!query.trim())
|
|
69
69
|
return "No query provided.";
|
|
70
70
|
const regex = buildSearchRegex(query);
|
|
@@ -96,8 +96,12 @@ export async function recallSearch(store, query) {
|
|
|
96
96
|
if (hits.length === 0)
|
|
97
97
|
return `No results found for "${query}".`;
|
|
98
98
|
const total = hits.length;
|
|
99
|
-
const
|
|
100
|
-
|
|
99
|
+
const paged = hits.slice(offset, offset + maxResults);
|
|
100
|
+
const range = offset > 0 || paged.length < total
|
|
101
|
+
? ` (showing ${offset + 1}–${offset + paged.length} of ${total})`
|
|
102
|
+
: "";
|
|
103
|
+
const summary = `Found ${total} match${total === 1 ? "" : "es"} for "${query}"${range}`;
|
|
104
|
+
return `${summary}\n\n${paged.join("\n\n")}`;
|
|
101
105
|
}
|
|
102
106
|
export async function recallExpand(store, id) {
|
|
103
107
|
const entry = await store.findById(id);
|
|
@@ -114,9 +118,15 @@ export async function recallExpand(store, id) {
|
|
|
114
118
|
return `${header}\n\n${body}`;
|
|
115
119
|
return `${header}\n\n(no expanded content available — recall cache may have been cleared)`;
|
|
116
120
|
}
|
|
117
|
-
export async function recallBrowse(store, limit = 25) {
|
|
118
|
-
const
|
|
119
|
-
|
|
121
|
+
export async function recallBrowse(store, offset = 0, limit = 25) {
|
|
122
|
+
const overRead = Math.max(limit * 3, offset + limit);
|
|
123
|
+
const allLines = await readSummaryLines(store, overRead);
|
|
124
|
+
if (allLines.length === 0)
|
|
120
125
|
return "No conversation history.";
|
|
121
|
-
|
|
126
|
+
const end = Math.min(offset + limit, allLines.length);
|
|
127
|
+
const paged = allLines.slice(offset, end);
|
|
128
|
+
const range = offset > 0 || end < allLines.length
|
|
129
|
+
? ` (entries ${offset + 1}–${end} of ${allLines.length} shown)`
|
|
130
|
+
: "";
|
|
131
|
+
return [`Recent summary entries${range}:`, ...paged.map((l) => ` ${l}`)].join("\n");
|
|
122
132
|
}
|
|
@@ -7,3 +7,11 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import type { AgentContext } from "../host-types.js";
|
|
9
9
|
export default function activate(ctx: AgentContext): void;
|
|
10
|
+
export interface CatalogModel {
|
|
11
|
+
id: string;
|
|
12
|
+
meta?: {
|
|
13
|
+
n_ctx?: number;
|
|
14
|
+
};
|
|
15
|
+
max_model_len?: number;
|
|
16
|
+
}
|
|
17
|
+
export declare function catalogContextWindow(m: CatalogModel): number | undefined;
|
|
@@ -18,11 +18,18 @@ export default function activate(ctx) {
|
|
|
18
18
|
id,
|
|
19
19
|
apiKey,
|
|
20
20
|
baseURL,
|
|
21
|
-
defaultModel: models[0],
|
|
21
|
+
defaultModel: models[0].id,
|
|
22
22
|
models,
|
|
23
23
|
});
|
|
24
24
|
}).catch(() => { });
|
|
25
25
|
}
|
|
26
|
+
export function catalogContextWindow(m) {
|
|
27
|
+
if (typeof m.meta?.n_ctx === "number" && m.meta.n_ctx > 0)
|
|
28
|
+
return m.meta.n_ctx;
|
|
29
|
+
if (typeof m.max_model_len === "number" && m.max_model_len > 0)
|
|
30
|
+
return m.max_model_len;
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
26
33
|
async function fetchModels(baseURL, apiKey) {
|
|
27
34
|
const headers = {};
|
|
28
35
|
if (apiKey && apiKey !== "no-key")
|
|
@@ -31,5 +38,5 @@ async function fetchModels(baseURL, apiKey) {
|
|
|
31
38
|
if (!res.ok)
|
|
32
39
|
return [];
|
|
33
40
|
const data = await res.json();
|
|
34
|
-
return (data.data ?? []).map((m) => m.id);
|
|
41
|
+
return (data.data ?? []).map((m) => ({ id: m.id, contextWindow: catalogContextWindow(m) }));
|
|
35
42
|
}
|
package/dist/agent/store.js
CHANGED
|
@@ -14,7 +14,12 @@ function escapeRegex(s) {
|
|
|
14
14
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
15
15
|
}
|
|
16
16
|
function compileSearchRegex(query) {
|
|
17
|
-
|
|
17
|
+
try {
|
|
18
|
+
return new RegExp(query, "i");
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return new RegExp(escapeRegex(query), "i");
|
|
22
|
+
}
|
|
18
23
|
}
|
|
19
24
|
function matchEntry(entry, re) {
|
|
20
25
|
const line = JSON.stringify(entry);
|
|
@@ -6,5 +6,6 @@
|
|
|
6
6
|
*/
|
|
7
7
|
/** Response reserve — tokens reserved for the model's output. */
|
|
8
8
|
export declare const RESPONSE_RESERVE = 8192;
|
|
9
|
+
export declare function resolveDefaultContextWindow(env?: Record<string, string | undefined>): number;
|
|
9
10
|
/** Fallback when contextWindow is unknown. */
|
|
10
|
-
export declare const DEFAULT_CONTEXT_WINDOW
|
|
11
|
+
export declare const DEFAULT_CONTEXT_WINDOW: number;
|
|
@@ -6,5 +6,10 @@
|
|
|
6
6
|
*/
|
|
7
7
|
/** Response reserve — tokens reserved for the model's output. */
|
|
8
8
|
export const RESPONSE_RESERVE = 8192;
|
|
9
|
+
const FALLBACK_CONTEXT_WINDOW = 60_000;
|
|
10
|
+
export function resolveDefaultContextWindow(env = process.env) {
|
|
11
|
+
const n = Number(env.AGENT_SH_DEFAULT_CONTEXT_WINDOW);
|
|
12
|
+
return Number.isInteger(n) && n > 0 ? n : FALLBACK_CONTEXT_WINDOW;
|
|
13
|
+
}
|
|
9
14
|
/** Fallback when contextWindow is unknown. */
|
|
10
|
-
export const DEFAULT_CONTEXT_WINDOW =
|
|
15
|
+
export const DEFAULT_CONTEXT_WINDOW = resolveDefaultContextWindow();
|
package/dist/cli/index.js
CHANGED
|
@@ -98,7 +98,6 @@ async function main() {
|
|
|
98
98
|
// Load before spawning the shell so PS1 lands below the banner.
|
|
99
99
|
const settings = getSettings();
|
|
100
100
|
await loadBuiltinExtensions(extCtx, settings.disabledBuiltins);
|
|
101
|
-
activateRollingHistory(extCtx);
|
|
102
101
|
const loadExtensionsTimeoutMs = 10000;
|
|
103
102
|
let loadedExtensions = [];
|
|
104
103
|
await Promise.race([
|
|
@@ -154,6 +153,7 @@ async function main() {
|
|
|
154
153
|
borderLine + "\n\n");
|
|
155
154
|
}
|
|
156
155
|
await core.activateBackend(config.backend);
|
|
156
|
+
activateRollingHistory(extCtx);
|
|
157
157
|
// 100ms sidesteps macOS SIGTTOU during fg-pgrp handoff.
|
|
158
158
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
159
159
|
shell = activateShell(extCtx, {
|
package/dist/core/event-bus.d.ts
CHANGED
|
@@ -58,6 +58,13 @@ export interface BusMeta {
|
|
|
58
58
|
name: string;
|
|
59
59
|
}
|
|
60
60
|
export type AnyListener = (name: string, payload: unknown, meta: BusMeta) => void;
|
|
61
|
+
/** A listener fault routed to the error reporter; `phase` is the callback site. */
|
|
62
|
+
export interface BusFault {
|
|
63
|
+
phase: "on" | "any" | "pipe" | "pipe-async";
|
|
64
|
+
event: string;
|
|
65
|
+
err: unknown;
|
|
66
|
+
}
|
|
67
|
+
export type ErrorReporter = (fault: BusFault) => void;
|
|
61
68
|
/**
|
|
62
69
|
* Typed event bus with two modes:
|
|
63
70
|
* - emit/on/off: fire-and-forget notifications
|
|
@@ -65,14 +72,22 @@ export type AnyListener = (name: string, payload: unknown, meta: BusMeta) => voi
|
|
|
65
72
|
* can modify the payload before passing to the next
|
|
66
73
|
*/
|
|
67
74
|
export declare class EventBus {
|
|
68
|
-
private
|
|
75
|
+
private listeners;
|
|
69
76
|
private pipeListeners;
|
|
70
77
|
private asyncPipeListeners;
|
|
71
78
|
private source;
|
|
72
79
|
private nextSeq;
|
|
73
80
|
private anyListeners;
|
|
81
|
+
/** Default fault sink, overridable via setErrorReporter: silent unless DEBUG. */
|
|
82
|
+
private reportError;
|
|
74
83
|
/** Set the source id stamped onto every emitted event. */
|
|
75
84
|
setSource(src: string): void;
|
|
85
|
+
/** Install a fault reporter. */
|
|
86
|
+
setErrorReporter(fn: ErrorReporter): void;
|
|
87
|
+
/** Report a fault; guarded so a broken reporter can't break dispatch. */
|
|
88
|
+
private fault;
|
|
89
|
+
/** Fire every listener for `name`, isolating faults. */
|
|
90
|
+
private notify;
|
|
76
91
|
/** Subscribe to every emitted event with full envelope. Returns unsubscribe. */
|
|
77
92
|
onAny(fn: AnyListener): () => void;
|
|
78
93
|
/** Stamp + dispatch — used by every emit path. */
|
package/dist/core/event-bus.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { EventEmitter } from "node:events";
|
|
2
1
|
/**
|
|
3
2
|
* Typed event bus with two modes:
|
|
4
3
|
* - emit/on/off: fire-and-forget notifications
|
|
@@ -6,16 +5,60 @@ import { EventEmitter } from "node:events";
|
|
|
6
5
|
* can modify the payload before passing to the next
|
|
7
6
|
*/
|
|
8
7
|
export class EventBus {
|
|
9
|
-
|
|
8
|
+
listeners = new Map();
|
|
10
9
|
pipeListeners = new Map();
|
|
11
10
|
asyncPipeListeners = new Map();
|
|
12
11
|
source = "0000";
|
|
13
12
|
nextSeq = 0;
|
|
14
13
|
anyListeners = [];
|
|
14
|
+
/** Default fault sink, overridable via setErrorReporter: silent unless DEBUG. */
|
|
15
|
+
reportError = ({ phase, event, err }) => {
|
|
16
|
+
if (process.env.DEBUG) {
|
|
17
|
+
const msg = err instanceof Error ? (err.stack ?? err.message) : String(err);
|
|
18
|
+
process.stderr.write(`[event-bus] ${phase} fault on "${event}": ${msg}\n`);
|
|
19
|
+
}
|
|
20
|
+
};
|
|
15
21
|
/** Set the source id stamped onto every emitted event. */
|
|
16
22
|
setSource(src) {
|
|
17
23
|
this.source = src;
|
|
18
24
|
}
|
|
25
|
+
/** Install a fault reporter. */
|
|
26
|
+
setErrorReporter(fn) {
|
|
27
|
+
this.reportError = fn;
|
|
28
|
+
}
|
|
29
|
+
/** Report a fault; guarded so a broken reporter can't break dispatch. */
|
|
30
|
+
fault(phase, event, err) {
|
|
31
|
+
try {
|
|
32
|
+
this.reportError({ phase, event, err });
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
/* swallow */
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Fire every listener for `name`, isolating faults. */
|
|
39
|
+
notify(name, payload) {
|
|
40
|
+
const arr = this.listeners.get(name);
|
|
41
|
+
if (!arr || arr.length === 0)
|
|
42
|
+
return;
|
|
43
|
+
// snapshot so a listener that (un)subscribes mid-dispatch can't shift iteration
|
|
44
|
+
if (arr.length === 1) {
|
|
45
|
+
try {
|
|
46
|
+
arr[0](payload);
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
this.fault("on", name, err);
|
|
50
|
+
}
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
for (const fn of arr.slice()) {
|
|
54
|
+
try {
|
|
55
|
+
fn(payload);
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
this.fault("on", name, err);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
19
62
|
/** Subscribe to every emitted event with full envelope. Returns unsubscribe. */
|
|
20
63
|
onAny(fn) {
|
|
21
64
|
this.anyListeners.push(fn);
|
|
@@ -38,18 +81,30 @@ export class EventBus {
|
|
|
38
81
|
try {
|
|
39
82
|
fn(name, payload, meta);
|
|
40
83
|
}
|
|
41
|
-
catch {
|
|
84
|
+
catch (err) {
|
|
85
|
+
this.fault("any", name, err);
|
|
86
|
+
}
|
|
42
87
|
}
|
|
43
88
|
}
|
|
44
|
-
this.
|
|
89
|
+
this.notify(name, payload);
|
|
45
90
|
}
|
|
46
91
|
/** Subscribe to a fire-and-forget event. */
|
|
47
92
|
on(event, fn) {
|
|
48
|
-
this.
|
|
93
|
+
let arr = this.listeners.get(event);
|
|
94
|
+
if (!arr) {
|
|
95
|
+
arr = [];
|
|
96
|
+
this.listeners.set(event, arr);
|
|
97
|
+
}
|
|
98
|
+
arr.push(fn);
|
|
49
99
|
}
|
|
50
100
|
/** Unsubscribe from a fire-and-forget event. */
|
|
51
101
|
off(event, fn) {
|
|
52
|
-
this.
|
|
102
|
+
const arr = this.listeners.get(event);
|
|
103
|
+
if (!arr)
|
|
104
|
+
return;
|
|
105
|
+
const idx = arr.indexOf(fn);
|
|
106
|
+
if (idx !== -1)
|
|
107
|
+
arr.splice(idx, 1);
|
|
53
108
|
}
|
|
54
109
|
/** Emit a fire-and-forget event. */
|
|
55
110
|
emit(event, payload) {
|
|
@@ -64,10 +119,12 @@ export class EventBus {
|
|
|
64
119
|
try {
|
|
65
120
|
fn(meta.name, payload, meta);
|
|
66
121
|
}
|
|
67
|
-
catch {
|
|
122
|
+
catch (err) {
|
|
123
|
+
this.fault("any", meta.name, err);
|
|
124
|
+
}
|
|
68
125
|
}
|
|
69
126
|
}
|
|
70
|
-
this.
|
|
127
|
+
this.notify(meta.name, payload);
|
|
71
128
|
}
|
|
72
129
|
/**
|
|
73
130
|
* Transform-then-notify: run the payload through any registered pipe
|
|
@@ -120,13 +177,13 @@ export class EventBus {
|
|
|
120
177
|
try {
|
|
121
178
|
const out = fn(result);
|
|
122
179
|
if (out && typeof out.then === "function") {
|
|
123
|
-
|
|
180
|
+
this.fault("pipe", String(event), new Error("async handler in sync pipe — use onPipeAsync instead"));
|
|
124
181
|
continue;
|
|
125
182
|
}
|
|
126
183
|
result = out;
|
|
127
184
|
}
|
|
128
185
|
catch (err) {
|
|
129
|
-
|
|
186
|
+
this.fault("pipe", String(event), err);
|
|
130
187
|
}
|
|
131
188
|
}
|
|
132
189
|
return result;
|
|
@@ -165,7 +222,12 @@ export class EventBus {
|
|
|
165
222
|
return payload;
|
|
166
223
|
let result = payload;
|
|
167
224
|
for (const fn of listeners) {
|
|
168
|
-
|
|
225
|
+
try {
|
|
226
|
+
result = await fn(result);
|
|
227
|
+
}
|
|
228
|
+
catch (err) {
|
|
229
|
+
this.fault("pipe-async", String(event), err);
|
|
230
|
+
}
|
|
169
231
|
}
|
|
170
232
|
return result;
|
|
171
233
|
}
|
package/dist/core/index.js
CHANGED
|
@@ -27,6 +27,24 @@ export function createCore(config) {
|
|
|
27
27
|
// should accept ≥6 hex chars.
|
|
28
28
|
const instanceId = crypto.randomBytes(3).toString("hex");
|
|
29
29
|
bus.setSource(instanceId);
|
|
30
|
+
// Surface faults on ui:error; `surfacing` stops a faulting renderer from looping.
|
|
31
|
+
let surfacing = false;
|
|
32
|
+
bus.setErrorReporter(({ phase, event, err }) => {
|
|
33
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
34
|
+
if (process.env.DEBUG) {
|
|
35
|
+
const full = err instanceof Error ? (err.stack ?? err.message) : detail;
|
|
36
|
+
process.stderr.write(`[event-bus] ${phase} fault on "${event}": ${full}\n`);
|
|
37
|
+
}
|
|
38
|
+
if (surfacing)
|
|
39
|
+
return;
|
|
40
|
+
surfacing = true;
|
|
41
|
+
try {
|
|
42
|
+
bus.emit("ui:error", { message: `Handler error on "${event}": ${detail}` });
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
surfacing = false;
|
|
46
|
+
}
|
|
47
|
+
});
|
|
30
48
|
handlers.define("config:get-app-config", () => config);
|
|
31
49
|
handlers.define("cwd", () => process.cwd());
|
|
32
50
|
// Empty defaults so advisors can wrap these regardless of load order;
|