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.
- package/LICENSE +21 -0
- package/README.md +1 -1
- package/dist/agent/agent-loop.d.ts +3 -0
- package/dist/agent/agent-loop.js +19 -6
- package/dist/agent/events.d.ts +3 -0
- 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/host-types.d.ts +6 -0
- package/dist/agent/index.js +5 -1
- package/dist/agent/llm-client.d.ts +2 -0
- package/dist/agent/llm-client.js +2 -2
- package/dist/agent/providers/openai-compatible.d.ts +8 -0
- package/dist/agent/providers/openai-compatible.js +9 -2
- package/dist/agent/providers/openrouter.js +11 -1
- 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/strategies/bash.js +10 -2
- 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/package.json +1 -1
- package/examples/extensions/ashi/src/chat/assistant.ts +8 -4
- package/examples/extensions/ashi/src/cli.ts +8 -0
- package/examples/extensions/ashi/src/compaction.ts +4 -7
- package/examples/extensions/ashi/src/frontend.ts +6 -3
- package/examples/extensions/ashi/src/renderers/pi-tui/inline-image.ts +145 -0
- package/examples/extensions/ashi/src/renderers/pi-tui/nodes.ts +51 -1
- package/examples/extensions/ashi/src/schema.ts +8 -2
- package/examples/extensions/ashi/src/user-shell-intents.ts +4 -1
- package/examples/extensions/command-suggest.ts +4 -0
- package/examples/extensions/latex-images.ts +152 -7
- package/examples/extensions/solarized-theme.ts +11 -0
- package/package.json +1 -1
- package/src/agent/agent-loop.ts +19 -6
- package/src/agent/events.ts +1 -0
- package/src/agent/extensions/rolling-history/index.ts +20 -8
- package/src/agent/extensions/rolling-history/recall.ts +28 -7
- package/src/agent/host-types.ts +2 -0
- package/src/agent/index.ts +7 -1
- package/src/agent/llm-client.ts +4 -2
- package/src/agent/providers/openai-compatible.ts +19 -4
- package/src/agent/providers/openrouter.ts +10 -1
- 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/strategies/bash.ts +10 -2
- 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/src/agent/agent-loop.ts
CHANGED
|
@@ -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 (
|
|
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: `
|
|
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
|
|
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) {
|
package/src/agent/events.ts
CHANGED
|
@@ -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
|
|
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
|
}
|
package/src/agent/host-types.ts
CHANGED
|
@@ -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
|
|
package/src/agent/index.ts
CHANGED
|
@@ -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
|
|
package/src/agent/llm-client.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
|
@@ -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", {
|
|
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,
|
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
|
|
|
@@ -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
|
-
`
|
|
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",
|