agent-sh 0.15.0 → 0.15.2

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 (124) hide show
  1. package/dist/agent/agent-loop.js +11 -8
  2. package/dist/agent/events.d.ts +4 -0
  3. package/docs/README.md +14 -0
  4. package/docs/agent.md +398 -0
  5. package/docs/architecture.md +196 -0
  6. package/docs/context-management.md +200 -0
  7. package/docs/extensions.md +951 -0
  8. package/docs/library.md +84 -0
  9. package/docs/troubleshooting.md +65 -0
  10. package/docs/tui-composition.md +294 -0
  11. package/docs/usage.md +306 -0
  12. package/examples/extensions/ash-scheme/package.json +1 -1
  13. package/examples/extensions/ashi/EXTENDING.md +2 -2
  14. package/examples/extensions/ashi/README.md +2 -2
  15. package/examples/extensions/ashi/docs/ui-surface-protocol.md +1 -1
  16. package/examples/extensions/ashi/package.json +5 -3
  17. package/examples/extensions/ashi/src/chat/tool-group.ts +3 -2
  18. package/examples/extensions/ashi/src/cli.ts +9 -8
  19. package/examples/extensions/ashi/src/dialogs.ts +16 -1
  20. package/examples/extensions/ashi/src/events.ts +1 -0
  21. package/examples/extensions/ashi/src/frontend.ts +26 -6
  22. package/examples/extensions/ashi/src/renderer.ts +24 -4
  23. package/examples/extensions/ashi/src/renderers/pi-tui/schema-mount.ts +4 -3
  24. package/examples/extensions/ashi/src/renderers/pi-tui/tool-group.ts +5 -8
  25. package/examples/extensions/ashi/src/ui.ts +11 -0
  26. package/examples/extensions/ashi-ink/package.json +2 -2
  27. package/examples/extensions/claude-code-bridge/package.json +1 -1
  28. package/examples/extensions/opencode-bridge/package.json +1 -1
  29. package/package.json +3 -1
  30. package/src/agent/agent-loop.ts +1566 -0
  31. package/src/agent/entry-format.ts +19 -0
  32. package/src/agent/events.ts +153 -0
  33. package/src/agent/extensions/rolling-history/constants.ts +1 -0
  34. package/src/agent/extensions/rolling-history/index.ts +202 -0
  35. package/src/agent/extensions/rolling-history/recall.ts +131 -0
  36. package/src/agent/extensions/rolling-history/strategy.ts +404 -0
  37. package/src/agent/host-types.ts +192 -0
  38. package/src/agent/index.ts +591 -0
  39. package/src/agent/live-view.ts +279 -0
  40. package/src/agent/llm-client.ts +111 -0
  41. package/src/agent/llm-facade.ts +43 -0
  42. package/src/agent/normalize-args.ts +61 -0
  43. package/src/agent/nuclear-form.ts +382 -0
  44. package/src/agent/providers/deepseek.ts +39 -0
  45. package/src/agent/providers/ollama.ts +92 -0
  46. package/src/agent/providers/openai-compatible.ts +36 -0
  47. package/src/agent/providers/openai.ts +52 -0
  48. package/src/agent/providers/opencode.ts +142 -0
  49. package/src/agent/providers/openrouter.ts +105 -0
  50. package/src/agent/providers/zai-coding-plan.ts +33 -0
  51. package/src/agent/session-store.ts +336 -0
  52. package/src/agent/skills.ts +228 -0
  53. package/src/agent/store.ts +310 -0
  54. package/src/agent/subagent.ts +305 -0
  55. package/src/agent/system-prompt.ts +151 -0
  56. package/src/agent/token-budget.ts +12 -0
  57. package/src/agent/tool-protocol.ts +722 -0
  58. package/src/agent/tool-registry.ts +66 -0
  59. package/src/agent/tools/bash.ts +95 -0
  60. package/src/agent/tools/edit-file.ts +154 -0
  61. package/src/agent/tools/expand-home.ts +7 -0
  62. package/src/agent/tools/glob.ts +108 -0
  63. package/src/agent/tools/grep.ts +228 -0
  64. package/src/agent/tools/list-skills.ts +37 -0
  65. package/src/agent/tools/ls.ts +81 -0
  66. package/src/agent/tools/pwsh.ts +140 -0
  67. package/src/agent/tools/read-file.ts +164 -0
  68. package/src/agent/tools/write-file.ts +72 -0
  69. package/src/agent/types.ts +149 -0
  70. package/src/cli/args.ts +91 -0
  71. package/src/cli/auth/cli.ts +244 -0
  72. package/src/cli/auth/discover.ts +52 -0
  73. package/src/cli/auth/keys.ts +143 -0
  74. package/src/cli/index.ts +295 -0
  75. package/src/cli/init.ts +74 -0
  76. package/src/cli/install.ts +439 -0
  77. package/src/cli/shell-env.ts +68 -0
  78. package/src/cli/subcommands.ts +24 -0
  79. package/src/core/event-bus.ts +252 -0
  80. package/src/core/extension-loader.ts +347 -0
  81. package/src/core/index.ts +152 -0
  82. package/src/core/settings.ts +398 -0
  83. package/src/core/types.ts +61 -0
  84. package/src/extensions/file-autocomplete.ts +71 -0
  85. package/src/extensions/index.ts +38 -0
  86. package/src/extensions/slash-commands/events.ts +14 -0
  87. package/src/extensions/slash-commands/index.ts +269 -0
  88. package/src/shell/events.ts +73 -0
  89. package/src/shell/host-types.ts +150 -0
  90. package/src/shell/index.ts +159 -0
  91. package/src/shell/input-handler.ts +505 -0
  92. package/src/shell/output-parser.ts +156 -0
  93. package/src/shell/shell-context.ts +193 -0
  94. package/src/shell/shell.ts +414 -0
  95. package/src/shell/strategies/bash.ts +83 -0
  96. package/src/shell/strategies/fish.ts +77 -0
  97. package/src/shell/strategies/index.ts +24 -0
  98. package/src/shell/strategies/types.ts +64 -0
  99. package/src/shell/strategies/zsh.ts +92 -0
  100. package/src/shell/terminal.ts +124 -0
  101. package/src/shell/tui-input-view.ts +222 -0
  102. package/src/shell/tui-renderer.ts +1126 -0
  103. package/src/utils/ansi.ts +140 -0
  104. package/src/utils/box-frame.ts +138 -0
  105. package/src/utils/compositor.ts +157 -0
  106. package/src/utils/diff-renderer.ts +829 -0
  107. package/src/utils/diff.ts +244 -0
  108. package/src/utils/executor.ts +305 -0
  109. package/src/utils/file-watcher.ts +110 -0
  110. package/src/utils/floating-panel.ts +1160 -0
  111. package/src/utils/handler-registry.ts +110 -0
  112. package/src/utils/line-editor.ts +636 -0
  113. package/src/utils/markdown.ts +437 -0
  114. package/src/utils/message-utils.ts +113 -0
  115. package/src/utils/package-version.ts +12 -0
  116. package/src/utils/palette.ts +64 -0
  117. package/src/utils/ref-counter.ts +9 -0
  118. package/src/utils/ripgrep-path.ts +17 -0
  119. package/src/utils/shell-output-spill.ts +76 -0
  120. package/src/utils/stream-transform.ts +292 -0
  121. package/src/utils/terminal-buffer.ts +213 -0
  122. package/src/utils/tool-display.ts +315 -0
  123. package/src/utils/tool-interactive.ts +71 -0
  124. package/src/utils/tty.ts +14 -0
@@ -0,0 +1,252 @@
1
+ import { EventEmitter } from "node:events";
2
+
3
+ export interface BackendRegistration {
4
+ name: string;
5
+ kill: () => void;
6
+ start?: () => Promise<void>;
7
+ }
8
+
9
+ /** Typed event map — every event has a known payload shape. */
10
+ export interface BusEvents {
11
+ "core:extensions-loaded": { names: string[] };
12
+
13
+ /** Cross-cutting "config might have changed, repaint" signal. */
14
+ "config:changed": Record<string, never>;
15
+
16
+ /** Universal UI feedback channel (any frontend may render; silently
17
+ * ignored without one). */
18
+ "ui:info": { message: string };
19
+ "ui:error": { message: string };
20
+ "ui:suggestion": { text: string };
21
+
22
+ /** Backend registry — core owns these; every backend (ash, bridges)
23
+ * emits register, switch/list flow through here too. */
24
+ "agent:register-backend": BackendRegistration;
25
+ "config:get-backends": { names: string[]; active: string | null };
26
+ "config:switch-backend": { name: string };
27
+ "config:list-backends": Record<string, never>;
28
+ }
29
+
30
+ // ── Content block types (used by transform pipeline) ────────────
31
+
32
+ export type ContentBlock =
33
+ | { type: "text"; text: string }
34
+ | { type: "code-block"; language: string; code: string }
35
+ | { type: "image"; data: Buffer }
36
+ | { type: "raw"; escape: string };
37
+
38
+ type Listener<T> = (payload: T) => void;
39
+ type PipeListener<T> = (payload: T) => T;
40
+ type AsyncPipeListener<T> = (payload: T) => T | Promise<T>;
41
+
42
+ /** Envelope stamped on every emitted event. */
43
+ export interface BusMeta {
44
+ source: string; // emitting agent's instanceId
45
+ ts: number; // milliseconds since epoch
46
+ id: string; // monotonic per-bus, "<source>:<n>"
47
+ name: string;
48
+ }
49
+
50
+ export type AnyListener = (name: string, payload: unknown, meta: BusMeta) => void;
51
+
52
+ /**
53
+ * Typed event bus with two modes:
54
+ * - emit/on/off: fire-and-forget notifications
55
+ * - emitPipe/onPipe: synchronous transform chain where each listener
56
+ * can modify the payload before passing to the next
57
+ */
58
+ export class EventBus {
59
+ private emitter = new EventEmitter().setMaxListeners(0);
60
+ private pipeListeners = new Map<string, PipeListener<any>[]>();
61
+ private asyncPipeListeners = new Map<string, AsyncPipeListener<any>[]>();
62
+ private source = "0000";
63
+ private nextSeq = 0;
64
+ private anyListeners: AnyListener[] = [];
65
+
66
+ /** Set the source id stamped onto every emitted event. */
67
+ setSource(src: string): void {
68
+ this.source = src;
69
+ }
70
+
71
+ /** Subscribe to every emitted event with full envelope. Returns unsubscribe. */
72
+ onAny(fn: AnyListener): () => void {
73
+ this.anyListeners.push(fn);
74
+ return () => {
75
+ const i = this.anyListeners.indexOf(fn);
76
+ if (i !== -1) this.anyListeners.splice(i, 1);
77
+ };
78
+ }
79
+
80
+ /** Stamp + dispatch — used by every emit path. */
81
+ private dispatch(name: string, payload: unknown): void {
82
+ if (this.anyListeners.length > 0) {
83
+ const meta: BusMeta = {
84
+ source: this.source,
85
+ ts: Date.now(),
86
+ id: `${this.source}:${this.nextSeq++}`,
87
+ name,
88
+ };
89
+ for (const fn of this.anyListeners) {
90
+ try { fn(name, payload, meta); } catch { /* swallow */ }
91
+ }
92
+ }
93
+ this.emitter.emit(name, payload);
94
+ }
95
+
96
+ /** Subscribe to a fire-and-forget event. */
97
+ on<K extends keyof BusEvents>(
98
+ event: K,
99
+ fn: Listener<BusEvents[K]>,
100
+ ): void {
101
+ this.emitter.on(event, fn);
102
+ }
103
+
104
+ /** Unsubscribe from a fire-and-forget event. */
105
+ off<K extends keyof BusEvents>(
106
+ event: K,
107
+ fn: Listener<BusEvents[K]>,
108
+ ): void {
109
+ this.emitter.off(event, fn);
110
+ }
111
+
112
+ /** Emit a fire-and-forget event. */
113
+ emit<K extends keyof BusEvents>(
114
+ event: K,
115
+ payload: BusEvents[K],
116
+ ): void {
117
+ this.dispatch(event, payload);
118
+ }
119
+
120
+ /** Re-dispatch an event with externally-supplied meta. Used by bridges
121
+ * and replay tools to preserve the original source/ts/id of remote or
122
+ * recorded events instead of restamping them as locally originated. */
123
+ relay(meta: BusMeta, payload: unknown): void {
124
+ if (this.anyListeners.length > 0) {
125
+ for (const fn of this.anyListeners) {
126
+ try { fn(meta.name, payload, meta); } catch { /* swallow */ }
127
+ }
128
+ }
129
+ this.emitter.emit(meta.name, payload);
130
+ }
131
+
132
+ /**
133
+ * Transform-then-notify: run the payload through any registered pipe
134
+ * listeners (transforms), then emit the final result to regular `on`
135
+ * listeners (renderers). This enables content pipelines where extensions
136
+ * modify data (e.g. render LaTeX → terminal image) before renderers see it.
137
+ */
138
+ emitTransform<K extends keyof BusEvents>(
139
+ event: K,
140
+ payload: BusEvents[K],
141
+ ): void {
142
+ let transformed: BusEvents[K];
143
+ try {
144
+ transformed = this.emitPipe(event, payload);
145
+ } catch (err) {
146
+ if (process.env.DEBUG) {
147
+ process.stderr.write(`[event-bus] pipe error on ${String(event)}: ${err}\n`);
148
+ }
149
+ transformed = payload; // fall back to untransformed
150
+ }
151
+ this.dispatch(event, transformed);
152
+ }
153
+
154
+ /** Register a transform listener for a pipeline event. */
155
+ onPipe<K extends keyof BusEvents>(
156
+ event: K,
157
+ fn: PipeListener<BusEvents[K]>,
158
+ ): void {
159
+ let listeners = this.pipeListeners.get(event);
160
+ if (!listeners) {
161
+ listeners = [];
162
+ this.pipeListeners.set(event, listeners);
163
+ }
164
+ listeners.push(fn);
165
+ }
166
+
167
+ /** Remove a transform listener from a pipeline event. */
168
+ offPipe<K extends keyof BusEvents>(
169
+ event: K,
170
+ fn: PipeListener<BusEvents[K]>,
171
+ ): void {
172
+ const listeners = this.pipeListeners.get(event);
173
+ if (!listeners) return;
174
+ const idx = listeners.indexOf(fn);
175
+ if (idx !== -1) listeners.splice(idx, 1);
176
+ }
177
+
178
+ /**
179
+ * Emit a pipeline event — each registered pipe listener receives the
180
+ * output of the previous one. Returns the final transformed payload.
181
+ * If no listeners are registered, returns the original payload unchanged.
182
+ */
183
+ emitPipe<K extends keyof BusEvents>(
184
+ event: K,
185
+ payload: BusEvents[K],
186
+ ): BusEvents[K] {
187
+ const listeners = this.pipeListeners.get(event);
188
+ if (!listeners) return payload;
189
+ let result = payload;
190
+ for (const fn of listeners) {
191
+ try {
192
+ const out = fn(result);
193
+ if (out && typeof (out as any).then === "function") {
194
+ console.error(`[event-bus] Warning: async handler in sync pipe "${String(event)}" — use onPipeAsync instead`);
195
+ continue;
196
+ }
197
+ result = out;
198
+ } catch (err) {
199
+ console.error(`[event-bus] Pipe handler error in "${String(event)}":`, err instanceof Error ? err.message : err);
200
+ }
201
+ }
202
+ return result;
203
+ }
204
+
205
+ /** Remove an async transform listener from a pipeline event. */
206
+ offPipeAsync<K extends keyof BusEvents>(
207
+ event: K,
208
+ fn: AsyncPipeListener<BusEvents[K]>,
209
+ ): void {
210
+ const listeners = this.asyncPipeListeners.get(event);
211
+ if (!listeners) return;
212
+ const idx = listeners.indexOf(fn);
213
+ if (idx !== -1) listeners.splice(idx, 1);
214
+ }
215
+
216
+ /** Register an async transform listener for a pipeline event. */
217
+ onPipeAsync<K extends keyof BusEvents>(
218
+ event: K,
219
+ fn: AsyncPipeListener<BusEvents[K]>,
220
+ ): void {
221
+ let listeners = this.asyncPipeListeners.get(event);
222
+ if (!listeners) {
223
+ listeners = [];
224
+ this.asyncPipeListeners.set(event, listeners);
225
+ }
226
+ listeners.push(fn);
227
+ }
228
+
229
+ /**
230
+ * Emit an async pipeline event. Two phases:
231
+ * 1. Notify — fire regular `on` listeners synchronously (e.g., TUI flushes state)
232
+ * 2. Transform — run async pipe listeners in series, each receiving the
233
+ * output of the previous (e.g., extension provides a permission decision)
234
+ *
235
+ * Returns the final transformed payload. If no pipe listeners are registered,
236
+ * returns the original payload unchanged (with safe defaults).
237
+ */
238
+ async emitPipeAsync<K extends keyof BusEvents>(
239
+ event: K,
240
+ payload: BusEvents[K],
241
+ ): Promise<BusEvents[K]> {
242
+ this.dispatch(event, payload);
243
+
244
+ const listeners = this.asyncPipeListeners.get(event);
245
+ if (!listeners) return payload;
246
+ let result = payload;
247
+ for (const fn of listeners) {
248
+ result = await fn(result);
249
+ }
250
+ return result;
251
+ }
252
+ }
@@ -0,0 +1,347 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import type { ExtensionContext } from "../shell/host-types.js";
4
+ import type { EventBus } from "./event-bus.js";
5
+ import { CONFIG_DIR, getSettings } from "./settings.js";
6
+
7
+ const EXT_DIR = path.join(CONFIG_DIR, "extensions");
8
+
9
+ const TS_EXTS = [".ts", ".tsx", ".mts"];
10
+ const SCRIPT_EXTS = [".js", ".mjs", ".ts", ".tsx", ".mts"];
11
+
12
+ let tsRegistered = false;
13
+ let tsxUnregister: (() => Promise<void>) | null = null;
14
+
15
+ /**
16
+ * Register tsx's ESM loader for .ts file support.
17
+ *
18
+ * Called before importing .ts extensions. The tsx loader uses Node's
19
+ * module.register() which creates a background thread with a MessageChannel.
20
+ * On reload, the old loader may become stale (the MessageChannel port can be
21
+ * GC'd or the loader thread can stop responding), so we unregister the old
22
+ * handle and re-register on each reload.
23
+ *
24
+ * Initial load: registers fresh.
25
+ * Reload: unregisters old handle, registers new one.
26
+ * Non-reload calls within the same load: no-op (tsRegistered guard).
27
+ */
28
+ async function ensureTsSupport(force = false): Promise<void> {
29
+ if (tsRegistered && !force) return;
30
+ try {
31
+ if (tsxUnregister) {
32
+ try { await tsxUnregister(); } catch { /* ignore stale handle */ }
33
+ }
34
+ const { register } = await import("tsx/esm/api");
35
+ tsxUnregister = register();
36
+ tsRegistered = true;
37
+ } catch {
38
+ // tsx not available — TS extensions will fail with a clear error
39
+ }
40
+ }
41
+
42
+ // ── Scoped context for reloadable extensions ────────────────────
43
+
44
+ type Cleanup = () => void;
45
+
46
+ /**
47
+ * Wrap an ExtensionContext to track all registrations (bus.on, advise,
48
+ * command:register, plus all agent/shell surface registrars). Returns
49
+ * the wrapped context and a dispose() that tears down everything
50
+ * registered through it.
51
+ */
52
+ function createScopedContext(ctx: ExtensionContext, extensionName: string): { scoped: ExtensionContext; dispose: () => void } {
53
+ const cleanups: Cleanup[] = [];
54
+ const bus = ctx.bus;
55
+
56
+ const scopedBus: EventBus = Object.create(bus);
57
+ scopedBus.on = ((event: any, fn: any) => {
58
+ bus.on(event, fn);
59
+ cleanups.push(() => bus.off(event, fn));
60
+ }) as typeof bus.on;
61
+ scopedBus.onPipe = ((event: any, fn: any) => {
62
+ bus.onPipe(event, fn);
63
+ cleanups.push(() => bus.offPipe(event, fn));
64
+ }) as typeof bus.onPipe;
65
+
66
+ // Wrap any (name, fn) → unsubscribe registrar so its disposer runs on teardown.
67
+ const trackUnsub = <A, B>(fn: (a: A, b: B) => () => void) => (a: A, b: B) => {
68
+ const unsub = fn(a, b);
69
+ cleanups.push(unsub);
70
+ return unsub;
71
+ };
72
+
73
+ // ── substrate / sugar ──────────────────────────────────────
74
+ const scopedAdvise: typeof ctx.advise = trackUnsub(ctx.advise);
75
+ // Without this, reloading an extension stacks its commands (old + new)
76
+ // in the slash-commands registry.
77
+ const scopedRegisterCommand: typeof ctx.registerCommand = (name, description, handler) => {
78
+ ctx.registerCommand(name, description, handler);
79
+ cleanups.push(() => bus.emit("command:unregister", { name }));
80
+ };
81
+ const scopedAdviseCommand: typeof ctx.adviseCommand = trackUnsub(ctx.adviseCommand);
82
+
83
+ // ── agent surface (optional — bridge backends omit it) ───
84
+ const agent = ctx.agent;
85
+ let scopedAgent: typeof agent;
86
+ if (agent) {
87
+ scopedAgent = {
88
+ ...agent,
89
+ registerTool: (tool) => {
90
+ agent.registerTool(tool);
91
+ cleanups.push(() => agent.unregisterTool(tool.name));
92
+ },
93
+ adviseTool: trackUnsub(agent.adviseTool),
94
+ adviseToolSchema: trackUnsub(agent.adviseToolSchema),
95
+ registerInstruction: (name, text) => {
96
+ agent.registerInstruction(name, text);
97
+ cleanups.push(() => agent.removeInstruction(name));
98
+ },
99
+ adviseInstruction: trackUnsub(agent.adviseInstruction),
100
+ registerSkill: (name, description, filePath) => {
101
+ agent.registerSkill(name, description, filePath);
102
+ cleanups.push(() => agent.removeSkill(name));
103
+ },
104
+ adviseSkill: trackUnsub(agent.adviseSkill),
105
+ registerContextProducer: (name, producer, opts) => {
106
+ const dispose = agent.registerContextProducer(name, producer, opts);
107
+ cleanups.push(dispose);
108
+ return dispose;
109
+ },
110
+ };
111
+ }
112
+
113
+ // ── shell surface (optional — headless backends omit it) ──
114
+ const shell = ctx.shell;
115
+ let scopedShell: typeof shell;
116
+ if (shell) {
117
+ scopedShell = {
118
+ ...shell,
119
+ adviseInputMode: trackUnsub(shell.adviseInputMode),
120
+ };
121
+ }
122
+
123
+ const scoped: ExtensionContext = {
124
+ ...ctx,
125
+ bus: scopedBus,
126
+ advise: scopedAdvise,
127
+ registerCommand: scopedRegisterCommand,
128
+ adviseCommand: scopedAdviseCommand,
129
+ agent: scopedAgent,
130
+ shell: scopedShell,
131
+ onDispose: (fn: () => void) => { cleanups.push(fn); },
132
+ };
133
+
134
+ const dispose = () => {
135
+ // Snapshot: a re-registering cleanup appends a new cleanup, and iterating
136
+ // the live array would run it and undo the restore in the same pass.
137
+ for (const fn of cleanups.slice()) {
138
+ try { fn(); } catch { /* ignore */ }
139
+ }
140
+ cleanups.length = 0;
141
+ };
142
+
143
+ return { scoped, dispose };
144
+ }
145
+
146
+ const extensionDisposers = new Map<string, () => void>();
147
+
148
+
149
+ /**
150
+ * Load extensions from three sources (merged, deduplicated):
151
+ *
152
+ * 1. CLI flags: -e / --extensions (npm packages or file paths)
153
+ * 2. settings.json: ~/.agent-sh/settings.json → extensions[]
154
+ * 3. Extensions dir: ~/.agent-sh/extensions/ (files and directories with index.{ts,js})
155
+ *
156
+ * Extension specifiers resolve as:
157
+ * - File path (relative or absolute) → import directly
158
+ * - Bare name → npm package (Node resolution)
159
+ *
160
+ * Each module should export a default or named `activate(ctx)` function.
161
+ * Errors are non-fatal — logged via ui:error and skipped.
162
+ */
163
+ export async function loadExtensions(
164
+ ctx: ExtensionContext,
165
+ cliExtensions?: string[],
166
+ ): Promise<string[]> {
167
+ const specifiers: string[] = [];
168
+
169
+ if (cliExtensions) {
170
+ specifiers.push(...cliExtensions);
171
+ }
172
+
173
+ const settings = getSettings();
174
+ if (settings.extensions.length > 0) {
175
+ specifiers.push(...settings.extensions);
176
+ }
177
+
178
+ const userSpecifiers = await discoverUserExtensions(ctx);
179
+ specifiers.push(...userSpecifiers);
180
+
181
+ const seen = new Set<string>();
182
+ const unique = specifiers.filter((s) => {
183
+ if (seen.has(s)) return false;
184
+ seen.add(s);
185
+ return true;
186
+ });
187
+
188
+ const loaded = await loadSpecifiers(unique, ctx, false);
189
+ return loaded;
190
+ }
191
+
192
+ async function discoverUserExtensions(ctx: ExtensionContext): Promise<string[]> {
193
+ const specifiers: string[] = [];
194
+ const disabled = new Set(getSettings().disabledExtensions ?? []);
195
+
196
+ let entries: import("node:fs").Dirent[];
197
+ try {
198
+ entries = await fs.readdir(EXT_DIR, { withFileTypes: true });
199
+ } catch (err) {
200
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return specifiers;
201
+ ctx.bus.emit("ui:error", {
202
+ message: `Failed to read extensions directory ${EXT_DIR}: ${err instanceof Error ? err.message : String(err)}`,
203
+ });
204
+ return specifiers;
205
+ }
206
+
207
+ for (const entry of entries) {
208
+ // Disable check: directory name for dir-extensions, or basename sans
209
+ // extension for file-extensions. Lets settings.json turn one off
210
+ // without renaming it.
211
+ const nameForDisable = entry.name.replace(/\.[^.]+$/, "");
212
+ if (disabled.has(nameForDisable)) continue;
213
+
214
+ const fullPath = path.join(EXT_DIR, entry.name);
215
+ try {
216
+ const isDir = entry.isDirectory() ||
217
+ (entry.isSymbolicLink() && (await fs.stat(fullPath)).isDirectory());
218
+ if (isDir) {
219
+ const indexFile = await findIndex(fullPath);
220
+ if (indexFile) specifiers.push(indexFile);
221
+ } else if (SCRIPT_EXTS.some((ext) => entry.name.endsWith(ext))) {
222
+ specifiers.push(fullPath);
223
+ }
224
+ } catch (err) {
225
+ ctx.bus.emit("ui:error", {
226
+ message: `Failed to inspect extension ${fullPath}: ${err instanceof Error ? err.message : String(err)}`,
227
+ });
228
+ }
229
+ }
230
+ return specifiers;
231
+ }
232
+
233
+ async function loadSpecifiers(
234
+ specifiers: string[],
235
+ ctx: ExtensionContext,
236
+ bustCache: boolean,
237
+ ): Promise<string[]> {
238
+ const loaded: string[] = [];
239
+ for (const specifier of specifiers) {
240
+ try {
241
+ let importPath = await resolveSpecifier(specifier);
242
+
243
+ if (TS_EXTS.some((ext) => importPath.endsWith(ext))) {
244
+ await ensureTsSupport(bustCache);
245
+ }
246
+ if (bustCache) {
247
+ const sep = importPath.includes("?") ? "&" : "?";
248
+ importPath += `${sep}t=${Date.now()}`;
249
+ }
250
+ const mod = await import(importPath);
251
+ // tsx may double-wrap default exports: mod.default.default
252
+ const activate = typeof mod.default === "function"
253
+ ? mod.default
254
+ : typeof mod.default?.default === "function"
255
+ ? mod.default.default
256
+ : mod.activate;
257
+ if (typeof activate === "function") {
258
+ const base = path.basename(specifier).replace(/\.(ts|js|mjs|mts|tsx)$/, "");
259
+ const name = base === "index" ? path.basename(path.dirname(specifier)) : base;
260
+
261
+ // Awaiting activate() lets extensions with async setup (e.g.
262
+ // openrouter fetching its model catalog) finish before we move
263
+ // on; a 10s outer timeout in index.ts guards against hangs.
264
+ extensionDisposers.get(name)?.();
265
+ const { scoped, dispose } = createScopedContext(ctx, name);
266
+ await activate(scoped);
267
+ extensionDisposers.set(name, dispose);
268
+ loaded.push(name);
269
+ }
270
+ } catch (err) {
271
+ ctx.bus.emit("ui:error", {
272
+ message: `Failed to load extension ${specifier}: ${err instanceof Error ? err.message : String(err)}`,
273
+ });
274
+ }
275
+ }
276
+ return loaded;
277
+ }
278
+
279
+ /**
280
+ * Reload user extensions (from ~/.agent-sh/extensions/).
281
+ * Tears down old registrations, busts the module cache, and re-activates.
282
+ */
283
+ export async function reloadExtensions(ctx: ExtensionContext): Promise<string[]> {
284
+ const specifiers = await discoverUserExtensions(ctx);
285
+ return loadSpecifiers(specifiers, ctx, true);
286
+ }
287
+
288
+ /**
289
+ * Find an index file in a directory extension.
290
+ */
291
+ async function findIndex(dir: string): Promise<string | null> {
292
+ for (const ext of SCRIPT_EXTS) {
293
+ const candidate = path.join(dir, `index${ext}`);
294
+ try {
295
+ await fs.access(candidate);
296
+ return candidate;
297
+ } catch {
298
+ // try next
299
+ }
300
+ }
301
+ return null;
302
+ }
303
+
304
+ /**
305
+ * Resolve a specifier to an importable string.
306
+ *
307
+ * - Relative path (starts with ".") → resolve from cwd, file:// URL
308
+ * - Absolute path → file:// URL (directories resolved to index file)
309
+ * - Bare name → npm package (let Node resolve)
310
+ */
311
+ async function resolveSpecifier(specifier: string): Promise<string> {
312
+ let resolved: string;
313
+
314
+ if (specifier.startsWith(".")) {
315
+ resolved = path.resolve(process.cwd(), specifier);
316
+ } else if (path.isAbsolute(specifier)) {
317
+ resolved = specifier;
318
+ } else {
319
+ // Distinguish bare npm specifier from a relative path lacking "./".
320
+ // Scoped packages ("@scope/pkg") contain "/" but are npm specifiers,
321
+ // so the "@" prefix takes precedence over the "/" heuristic.
322
+ if (specifier.includes("/") && !specifier.startsWith("@")) {
323
+ resolved = path.resolve(process.cwd(), specifier);
324
+ } else {
325
+ return specifier;
326
+ }
327
+ }
328
+
329
+ try {
330
+ const stat = await fs.stat(resolved);
331
+ if (stat.isDirectory()) {
332
+ const indexFile = await findIndex(resolved);
333
+ if (indexFile) {
334
+ return `file://${indexFile}`;
335
+ }
336
+ throw new Error(`No index file found in ${resolved}`);
337
+ }
338
+ } catch (err) {
339
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
340
+ // Not a directory, treat as file
341
+ } else if (err instanceof Error && err.message.startsWith("No index")) {
342
+ throw err;
343
+ }
344
+ }
345
+
346
+ return `file://${resolved}`;
347
+ }