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.
- package/dist/agent/agent-loop.js +11 -8
- package/dist/agent/events.d.ts +4 -0
- package/docs/README.md +14 -0
- package/docs/agent.md +398 -0
- package/docs/architecture.md +196 -0
- package/docs/context-management.md +200 -0
- package/docs/extensions.md +951 -0
- package/docs/library.md +84 -0
- package/docs/troubleshooting.md +65 -0
- package/docs/tui-composition.md +294 -0
- package/docs/usage.md +306 -0
- package/examples/extensions/ash-scheme/package.json +1 -1
- package/examples/extensions/ashi/EXTENDING.md +2 -2
- package/examples/extensions/ashi/README.md +2 -2
- package/examples/extensions/ashi/docs/ui-surface-protocol.md +1 -1
- package/examples/extensions/ashi/package.json +5 -3
- package/examples/extensions/ashi/src/chat/tool-group.ts +3 -2
- package/examples/extensions/ashi/src/cli.ts +9 -8
- package/examples/extensions/ashi/src/dialogs.ts +16 -1
- package/examples/extensions/ashi/src/events.ts +1 -0
- package/examples/extensions/ashi/src/frontend.ts +26 -6
- package/examples/extensions/ashi/src/renderer.ts +24 -4
- package/examples/extensions/ashi/src/renderers/pi-tui/schema-mount.ts +4 -3
- package/examples/extensions/ashi/src/renderers/pi-tui/tool-group.ts +5 -8
- package/examples/extensions/ashi/src/ui.ts +11 -0
- package/examples/extensions/ashi-ink/package.json +2 -2
- package/examples/extensions/claude-code-bridge/package.json +1 -1
- package/examples/extensions/opencode-bridge/package.json +1 -1
- package/package.json +3 -1
- package/src/agent/agent-loop.ts +1566 -0
- package/src/agent/entry-format.ts +19 -0
- package/src/agent/events.ts +153 -0
- package/src/agent/extensions/rolling-history/constants.ts +1 -0
- package/src/agent/extensions/rolling-history/index.ts +202 -0
- package/src/agent/extensions/rolling-history/recall.ts +131 -0
- package/src/agent/extensions/rolling-history/strategy.ts +404 -0
- package/src/agent/host-types.ts +192 -0
- package/src/agent/index.ts +591 -0
- package/src/agent/live-view.ts +279 -0
- package/src/agent/llm-client.ts +111 -0
- package/src/agent/llm-facade.ts +43 -0
- package/src/agent/normalize-args.ts +61 -0
- package/src/agent/nuclear-form.ts +382 -0
- package/src/agent/providers/deepseek.ts +39 -0
- package/src/agent/providers/ollama.ts +92 -0
- package/src/agent/providers/openai-compatible.ts +36 -0
- package/src/agent/providers/openai.ts +52 -0
- package/src/agent/providers/opencode.ts +142 -0
- package/src/agent/providers/openrouter.ts +105 -0
- package/src/agent/providers/zai-coding-plan.ts +33 -0
- package/src/agent/session-store.ts +336 -0
- package/src/agent/skills.ts +228 -0
- package/src/agent/store.ts +310 -0
- package/src/agent/subagent.ts +305 -0
- package/src/agent/system-prompt.ts +151 -0
- package/src/agent/token-budget.ts +12 -0
- package/src/agent/tool-protocol.ts +722 -0
- package/src/agent/tool-registry.ts +66 -0
- package/src/agent/tools/bash.ts +95 -0
- package/src/agent/tools/edit-file.ts +154 -0
- package/src/agent/tools/expand-home.ts +7 -0
- package/src/agent/tools/glob.ts +108 -0
- package/src/agent/tools/grep.ts +228 -0
- package/src/agent/tools/list-skills.ts +37 -0
- package/src/agent/tools/ls.ts +81 -0
- package/src/agent/tools/pwsh.ts +140 -0
- package/src/agent/tools/read-file.ts +164 -0
- package/src/agent/tools/write-file.ts +72 -0
- package/src/agent/types.ts +149 -0
- package/src/cli/args.ts +91 -0
- package/src/cli/auth/cli.ts +244 -0
- package/src/cli/auth/discover.ts +52 -0
- package/src/cli/auth/keys.ts +143 -0
- package/src/cli/index.ts +295 -0
- package/src/cli/init.ts +74 -0
- package/src/cli/install.ts +439 -0
- package/src/cli/shell-env.ts +68 -0
- package/src/cli/subcommands.ts +24 -0
- package/src/core/event-bus.ts +252 -0
- package/src/core/extension-loader.ts +347 -0
- package/src/core/index.ts +152 -0
- package/src/core/settings.ts +398 -0
- package/src/core/types.ts +61 -0
- package/src/extensions/file-autocomplete.ts +71 -0
- package/src/extensions/index.ts +38 -0
- package/src/extensions/slash-commands/events.ts +14 -0
- package/src/extensions/slash-commands/index.ts +269 -0
- package/src/shell/events.ts +73 -0
- package/src/shell/host-types.ts +150 -0
- package/src/shell/index.ts +159 -0
- package/src/shell/input-handler.ts +505 -0
- package/src/shell/output-parser.ts +156 -0
- package/src/shell/shell-context.ts +193 -0
- package/src/shell/shell.ts +414 -0
- package/src/shell/strategies/bash.ts +83 -0
- package/src/shell/strategies/fish.ts +77 -0
- package/src/shell/strategies/index.ts +24 -0
- package/src/shell/strategies/types.ts +64 -0
- package/src/shell/strategies/zsh.ts +92 -0
- package/src/shell/terminal.ts +124 -0
- package/src/shell/tui-input-view.ts +222 -0
- package/src/shell/tui-renderer.ts +1126 -0
- package/src/utils/ansi.ts +140 -0
- package/src/utils/box-frame.ts +138 -0
- package/src/utils/compositor.ts +157 -0
- package/src/utils/diff-renderer.ts +829 -0
- package/src/utils/diff.ts +244 -0
- package/src/utils/executor.ts +305 -0
- package/src/utils/file-watcher.ts +110 -0
- package/src/utils/floating-panel.ts +1160 -0
- package/src/utils/handler-registry.ts +110 -0
- package/src/utils/line-editor.ts +636 -0
- package/src/utils/markdown.ts +437 -0
- package/src/utils/message-utils.ts +113 -0
- package/src/utils/package-version.ts +12 -0
- package/src/utils/palette.ts +64 -0
- package/src/utils/ref-counter.ts +9 -0
- package/src/utils/ripgrep-path.ts +17 -0
- package/src/utils/shell-output-spill.ts +76 -0
- package/src/utils/stream-transform.ts +292 -0
- package/src/utils/terminal-buffer.ts +213 -0
- package/src/utils/tool-display.ts +315 -0
- package/src/utils/tool-interactive.ts +71 -0
- package/src/utils/tty.ts +14 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core kernel — EventBus + HandlerRegistry + backend registry. Knows
|
|
3
|
+
* nothing about LLMs, tools, or specific backends; backends (ash,
|
|
4
|
+
* claude-code-bridge, ...) register through `agent:register-backend`
|
|
5
|
+
* and core dispatches to whichever is configured as default.
|
|
6
|
+
*/
|
|
7
|
+
import { EventBus, type BackendRegistration } from "./event-bus.js";
|
|
8
|
+
// Side-effect imports so downstream tsc sees module-augmented BusEvents.
|
|
9
|
+
import "../shell/events.js";
|
|
10
|
+
import "../agent/events.js";
|
|
11
|
+
import "../extensions/slash-commands/events.js";
|
|
12
|
+
import type { AppConfig, ExtensionContext } from "../shell/host-types.js";
|
|
13
|
+
import * as settingsMod from "./settings.js";
|
|
14
|
+
import { HandlerRegistry } from "../utils/handler-registry.js";
|
|
15
|
+
import crypto from "node:crypto";
|
|
16
|
+
import * as fs from "node:fs";
|
|
17
|
+
import * as path from "node:path";
|
|
18
|
+
import { CONFIG_DIR } from "./settings.js";
|
|
19
|
+
|
|
20
|
+
export { EventBus } from "./event-bus.js";
|
|
21
|
+
export type { BusEvents, ContentBlock, BackendRegistration } from "./event-bus.js";
|
|
22
|
+
export type { CoreContext, CoreConfig } from "./types.js";
|
|
23
|
+
export type { AgentContext, AgentConfig, AgentSurface, AgentConfigSurface, Model, LlmInterface, LlmMessage, LlmSession } from "../agent/host-types.js";
|
|
24
|
+
export type { ShellContext, ShellConfig, ShellSurface, ShellConfigSurface, ExtensionContext, RemoteSession, RemoteSessionOptions, RenderSurface, InputModeConfig, TerminalSession, BlockTransformOptions, FencedBlockTransformOptions, AppConfig } from "../shell/host-types.js";
|
|
25
|
+
export { palette, setPalette, resetPalette } from "../utils/palette.js";
|
|
26
|
+
export type { ColorPalette } from "../utils/palette.js";
|
|
27
|
+
export type { AgentBackend, ToolDefinition, ImageContent } from "../agent/types.js";
|
|
28
|
+
export { runSubagent, type SubagentOptions } from "../agent/subagent.js";
|
|
29
|
+
export { LlmClient } from "../agent/llm-client.js";
|
|
30
|
+
export type { NuclearEntry } from "../agent/nuclear-form.js";
|
|
31
|
+
export { compileSearchRegex, matchEntry, formatNuclearLine } from "../agent/nuclear-form.js";
|
|
32
|
+
|
|
33
|
+
export interface AgentShellCore {
|
|
34
|
+
bus: EventBus;
|
|
35
|
+
handlers: HandlerRegistry;
|
|
36
|
+
/** Unique id for this agent process; used for shell-marker tagging and lineage tracking. */
|
|
37
|
+
instanceId: string;
|
|
38
|
+
/** Activates a registered backend by name (or persisted default / first registered). */
|
|
39
|
+
activateBackend(override?: string): Promise<void>;
|
|
40
|
+
extensionContext(opts: { quit: () => void }): ExtensionContext;
|
|
41
|
+
kill(): void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function createCore(config: AppConfig): AgentShellCore {
|
|
45
|
+
const bus = new EventBus();
|
|
46
|
+
const handlers = new HandlerRegistry();
|
|
47
|
+
// 3 bytes = 6 hex chars; legacy content may have 16-char iids so parsers
|
|
48
|
+
// should accept ≥6 hex chars.
|
|
49
|
+
const instanceId = crypto.randomBytes(3).toString("hex");
|
|
50
|
+
bus.setSource(instanceId);
|
|
51
|
+
handlers.define("config:get-app-config", () => config);
|
|
52
|
+
handlers.define("cwd", () => process.cwd());
|
|
53
|
+
|
|
54
|
+
// Empty defaults so advisors can wrap these regardless of load order;
|
|
55
|
+
// system-prompt:frontend is where the active frontend describes its surface.
|
|
56
|
+
handlers.define("dynamic-context:build", () => "");
|
|
57
|
+
handlers.define("query-context:build", () => "");
|
|
58
|
+
handlers.define("system-prompt:frontend", () => "");
|
|
59
|
+
|
|
60
|
+
const backends = new Map<string, BackendRegistration>();
|
|
61
|
+
let activeBackendName: string | null = null;
|
|
62
|
+
|
|
63
|
+
bus.on("agent:register-backend", (backend) => {
|
|
64
|
+
backends.set(backend.name, backend);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
bus.onPipe("config:get-backends", () => ({
|
|
68
|
+
names: [...backends.keys()],
|
|
69
|
+
active: activeBackendName,
|
|
70
|
+
}));
|
|
71
|
+
|
|
72
|
+
const activateByName = async (name: string): Promise<boolean> => {
|
|
73
|
+
const backend = backends.get(name);
|
|
74
|
+
if (!backend) {
|
|
75
|
+
bus.emit("ui:error", { message: `Unknown backend: ${name}` });
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
if (activeBackendName && activeBackendName !== name) {
|
|
79
|
+
backends.get(activeBackendName)?.kill();
|
|
80
|
+
}
|
|
81
|
+
activeBackendName = name;
|
|
82
|
+
await backend.start?.();
|
|
83
|
+
return true;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
bus.on("config:switch-backend", ({ name }) => {
|
|
87
|
+
activateByName(name).then((ok) => {
|
|
88
|
+
if (!ok) return;
|
|
89
|
+
settingsMod.updateSettings({ defaultBackend: name });
|
|
90
|
+
bus.emit("ui:info", { message: `Backend: ${name} (saved as default)` });
|
|
91
|
+
bus.emit("config:changed", {});
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
bus.on("config:list-backends", () => {
|
|
96
|
+
const list = [...backends.keys()]
|
|
97
|
+
.map((n) => n === activeBackendName ? `${n} (active)` : n)
|
|
98
|
+
.join(", ");
|
|
99
|
+
bus.emit("ui:info", { message: `Backends: ${list || "(none registered)"}` });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
bus,
|
|
104
|
+
handlers,
|
|
105
|
+
instanceId,
|
|
106
|
+
|
|
107
|
+
async activateBackend(override?: string) {
|
|
108
|
+
if (backends.size === 0) {
|
|
109
|
+
bus.emit("ui:info", { message: "No agent backend registered." });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const preferred = override ?? settingsMod.getSettings().defaultBackend;
|
|
113
|
+
const name = preferred && backends.has(preferred)
|
|
114
|
+
? preferred
|
|
115
|
+
: backends.keys().next().value!;
|
|
116
|
+
await activateByName(name);
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
extensionContext(opts) {
|
|
120
|
+
const ctx = {
|
|
121
|
+
bus,
|
|
122
|
+
instanceId,
|
|
123
|
+
quit: opts.quit,
|
|
124
|
+
define: (name: string, fn: (...args: any[]) => any) => handlers.define(name, fn),
|
|
125
|
+
advise: (name: string, wrapper: (next: (...args: any[]) => any, ...args: any[]) => any) => handlers.advise(name, wrapper),
|
|
126
|
+
call: (name: string, ...args: any[]) => handlers.call(name, ...args),
|
|
127
|
+
list: () => handlers.list(),
|
|
128
|
+
onDispose: () => {},
|
|
129
|
+
getExtensionSettings: settingsMod.getExtensionSettings,
|
|
130
|
+
getStoragePath: (namespace: string) => {
|
|
131
|
+
const dir = path.join(CONFIG_DIR, namespace);
|
|
132
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
133
|
+
return dir;
|
|
134
|
+
},
|
|
135
|
+
registerCommand: (name: string, description: string, handler: (args: string) => Promise<void> | void) =>
|
|
136
|
+
bus.emit("command:register", { name, description, handler }),
|
|
137
|
+
adviseCommand: (name: string, advisor: (next: (args: string) => Promise<void> | void, args: string) => Promise<void> | void) => {
|
|
138
|
+
const key = name.startsWith("/") ? name : `/${name}`;
|
|
139
|
+
return handlers.advise(`command:${key}`, advisor as Parameters<typeof handlers.advise>[1]);
|
|
140
|
+
},
|
|
141
|
+
} as unknown as ExtensionContext;
|
|
142
|
+
return ctx;
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
kill() {
|
|
146
|
+
if (activeBackendName) {
|
|
147
|
+
backends.get(activeBackendName)?.kill();
|
|
148
|
+
activeBackendName = null;
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User settings loaded from ~/.agent-sh/settings.json.
|
|
3
|
+
*
|
|
4
|
+
* Settings are loaded once at startup and available synchronously
|
|
5
|
+
* throughout the app. Unknown keys are preserved on write.
|
|
6
|
+
*/
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
import * as os from "node:os";
|
|
10
|
+
|
|
11
|
+
/** Root config directory. Override via AGENT_SH_HOME for isolated instances
|
|
12
|
+
* (testing, multi-agent setups). Path is resolved at module load. */
|
|
13
|
+
export const CONFIG_DIR = process.env.AGENT_SH_HOME
|
|
14
|
+
? path.resolve(process.env.AGENT_SH_HOME)
|
|
15
|
+
: path.join(os.homedir(), ".agent-sh");
|
|
16
|
+
const SETTINGS_PATH = path.join(CONFIG_DIR, "settings.json");
|
|
17
|
+
|
|
18
|
+
/** Per-model capability overrides. */
|
|
19
|
+
export interface ModelCapabilityConfig {
|
|
20
|
+
/** Model identifier. */
|
|
21
|
+
id: string;
|
|
22
|
+
/** Whether the model supports reasoning/thinking tokens. */
|
|
23
|
+
reasoning?: boolean;
|
|
24
|
+
/** Context window size in tokens for this specific model. */
|
|
25
|
+
contextWindow?: number;
|
|
26
|
+
/** Max output tokens for this model. */
|
|
27
|
+
maxTokens?: number;
|
|
28
|
+
/** Echo reasoning_content back on assistant turns. Required by DeepSeek. */
|
|
29
|
+
echoReasoning?: boolean;
|
|
30
|
+
/** Content modalities the model supports (e.g. ["text", "image"]). */
|
|
31
|
+
modalities?: ("text" | "image")[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Provider profile — a named LLM configuration. */
|
|
35
|
+
export interface ProviderConfig {
|
|
36
|
+
/** API key (supports $ENV_VAR syntax for runtime expansion). */
|
|
37
|
+
apiKey?: string;
|
|
38
|
+
/** Base URL for OpenAI-compatible API. */
|
|
39
|
+
baseURL?: string;
|
|
40
|
+
/** Default model to use. Falls back to first entry in models list. */
|
|
41
|
+
defaultModel?: string;
|
|
42
|
+
/** Models available for cycling. Plain strings or objects with capabilities. */
|
|
43
|
+
models?: (string | ModelCapabilityConfig)[];
|
|
44
|
+
/** Context window size in tokens (e.g. 128000). Used for usage display. */
|
|
45
|
+
contextWindow?: number;
|
|
46
|
+
/** Case-insensitive regex sources matched against model id; matches default
|
|
47
|
+
* to echoReasoning=true. Per-model echoReasoning still wins. */
|
|
48
|
+
echoReasoningPatterns?: string[];
|
|
49
|
+
/** Borrow another registered provider's reasoning request shape by id
|
|
50
|
+
* (e.g. "openrouter"). Defaults to OpenAI-compat. */
|
|
51
|
+
reasoningShape?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface Settings {
|
|
55
|
+
/** Extensions to load (npm packages or file paths). */
|
|
56
|
+
extensions?: string[];
|
|
57
|
+
/** Max agent query history entries to keep. */
|
|
58
|
+
historySize?: number;
|
|
59
|
+
|
|
60
|
+
// ── Provider profiles ─────────────────────────────────────
|
|
61
|
+
/** Named provider configurations. */
|
|
62
|
+
providers?: Record<string, ProviderConfig>;
|
|
63
|
+
/** Which provider to use by default. */
|
|
64
|
+
defaultProvider?: string;
|
|
65
|
+
/** Preferred agent backend (extension name, e.g. "pi", "claude-code"). */
|
|
66
|
+
defaultBackend?: string;
|
|
67
|
+
/** Default thinking/reasoning effort level for new sessions ("off"|"low"|"medium"|"high"). */
|
|
68
|
+
thinkingLevel?: string;
|
|
69
|
+
|
|
70
|
+
// ── Shell output spill ────────────────────────────────────
|
|
71
|
+
/** Shell output lines before spill-to-tempfile kicks in. */
|
|
72
|
+
shellTruncateThreshold?: number;
|
|
73
|
+
/** Lines kept from start of spilled shell output. */
|
|
74
|
+
shellHeadLines?: number;
|
|
75
|
+
/** Lines kept from end of spilled shell output. */
|
|
76
|
+
shellTailLines?: number;
|
|
77
|
+
|
|
78
|
+
// ── History ──────────────────────────────────────────────
|
|
79
|
+
/** Max history file size in bytes (default: 102400 = 100KB). */
|
|
80
|
+
historyMaxBytes?: number;
|
|
81
|
+
/** Number of prior history entries to load on startup (default: 50). */
|
|
82
|
+
historyStartupEntries?: number;
|
|
83
|
+
/**
|
|
84
|
+
* Override the history file path. Defaults to `~/.agent-sh/history`.
|
|
85
|
+
* The `AGENT_SH_HISTORY_FILE` env var takes precedence over this setting.
|
|
86
|
+
* Use a per-project path to keep sessions isolated (e.g. embedding apps
|
|
87
|
+
* that boot agent-sh as a library against a specific working tree).
|
|
88
|
+
*/
|
|
89
|
+
historyFilePath?: string;
|
|
90
|
+
autoCompact?: boolean;
|
|
91
|
+
autoCompactThreshold?: number;
|
|
92
|
+
|
|
93
|
+
// ── Display ───────────────────────────────────────────────
|
|
94
|
+
/** Max command output lines shown inline in TUI. */
|
|
95
|
+
maxCommandOutputLines?: number;
|
|
96
|
+
/** Max read tool output lines shown inline in TUI (0 = hide). */
|
|
97
|
+
readOutputMaxLines?: number;
|
|
98
|
+
/** Max diff lines rendered in the TUI (Infinity = no limit). */
|
|
99
|
+
diffMaxLines?: number;
|
|
100
|
+
/** Lines of head content shown when a brand-new file is created. */
|
|
101
|
+
newFilePreviewLines?: number;
|
|
102
|
+
|
|
103
|
+
// ── Agent integration ─────────────────────────────────────
|
|
104
|
+
/** Tool protocol:
|
|
105
|
+
* "api" — all tools sent with full schema.
|
|
106
|
+
* "deferred" — extensions dispatched through `use_extension(name, args)` meta-tool.
|
|
107
|
+
* "deferred-lookup" — extensions loaded on demand via `load_tool(names[])`; once loaded, callable as first-class tools.
|
|
108
|
+
* "inline" — tools described as text.
|
|
109
|
+
*/
|
|
110
|
+
toolMode?: "api" | "deferred" | "deferred-lookup" | "inline";
|
|
111
|
+
/**
|
|
112
|
+
* Extra tool names treated as "core" in deferred / deferred-lookup mode —
|
|
113
|
+
* always sent with full schema instead of requiring an explicit load_tool
|
|
114
|
+
* call. Useful when an extension registers a substrate tool that should
|
|
115
|
+
* have first-class footing alongside the kernel built-ins.
|
|
116
|
+
*/
|
|
117
|
+
coreTools?: string[];
|
|
118
|
+
/** Additional directories to scan for skills (supports ~ expansion). */
|
|
119
|
+
skillPaths?: string[];
|
|
120
|
+
/**
|
|
121
|
+
* Enable the "diagnose" tool — lets the agent evaluate JavaScript
|
|
122
|
+
* expressions against its own runtime state. Powerful for introspection
|
|
123
|
+
* (e.g. this.conversation.turns.length) but grants arbitrary code
|
|
124
|
+
* execution within the agent process. Off by default because the
|
|
125
|
+
* agent already has unrestricted bash access — this is a convenience,
|
|
126
|
+
* not a new capability.
|
|
127
|
+
*/
|
|
128
|
+
diagnose?: boolean;
|
|
129
|
+
|
|
130
|
+
// ── Identity & startup ───────────────────────────────────
|
|
131
|
+
/** Show a startup banner when agent-sh launches. */
|
|
132
|
+
startupBanner?: boolean;
|
|
133
|
+
/** Show a subtle agent-sh indicator in the shell prompt. */
|
|
134
|
+
promptIndicator?: boolean;
|
|
135
|
+
|
|
136
|
+
// ── Built-in extensions ──────────────────────────────────
|
|
137
|
+
/** Names of built-in extensions to disable (e.g. ["command-suggest"]). */
|
|
138
|
+
disabledBuiltins?: string[];
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Names of user extensions in ~/.agent-sh/extensions/ to skip when
|
|
142
|
+
* auto-discovering. Match by basename without extension for files
|
|
143
|
+
* (e.g. "peer-mesh" matches peer-mesh.ts), or by directory name for
|
|
144
|
+
* directory-style extensions (e.g. "superash" matches superash/index.ts).
|
|
145
|
+
* Beats having to rename files to .disabled every time.
|
|
146
|
+
*/
|
|
147
|
+
disabledExtensions?: string[];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const DEFAULTS: Required<Settings> = {
|
|
151
|
+
extensions: [],
|
|
152
|
+
historySize: 500,
|
|
153
|
+
providers: {},
|
|
154
|
+
defaultProvider: undefined as unknown as string,
|
|
155
|
+
defaultBackend: "ash",
|
|
156
|
+
thinkingLevel: undefined as unknown as string,
|
|
157
|
+
toolMode: "api" as "api" | "deferred" | "deferred-lookup" | "inline",
|
|
158
|
+
coreTools: [],
|
|
159
|
+
shellTruncateThreshold: 20,
|
|
160
|
+
shellHeadLines: 10,
|
|
161
|
+
shellTailLines: 10,
|
|
162
|
+
historyMaxBytes: 104857600, // 100MB — history is only accessed via search/expand, never loaded wholesale
|
|
163
|
+
historyStartupEntries: 100,
|
|
164
|
+
historyFilePath: undefined as unknown as string,
|
|
165
|
+
autoCompact: true,
|
|
166
|
+
autoCompactThreshold: 0.5,
|
|
167
|
+
maxCommandOutputLines: 3,
|
|
168
|
+
readOutputMaxLines: 10,
|
|
169
|
+
diffMaxLines: Infinity,
|
|
170
|
+
newFilePreviewLines: 5,
|
|
171
|
+
skillPaths: [],
|
|
172
|
+
diagnose: false,
|
|
173
|
+
startupBanner: true,
|
|
174
|
+
promptIndicator: true,
|
|
175
|
+
disabledBuiltins: [],
|
|
176
|
+
disabledExtensions: [],
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
let cached: Settings | null = null;
|
|
180
|
+
let envOverrides: Partial<Settings> | null = null;
|
|
181
|
+
let sessionOverlay: Partial<Settings> = {};
|
|
182
|
+
|
|
183
|
+
export type SettingSource = "session" | "env" | "file" | "default";
|
|
184
|
+
|
|
185
|
+
function parseBoolEnv(raw: string | undefined, key: string): boolean | undefined {
|
|
186
|
+
if (raw === undefined) return undefined;
|
|
187
|
+
const v = raw.trim().toLowerCase();
|
|
188
|
+
if (v === "on" || v === "true" || v === "1") return true;
|
|
189
|
+
if (v === "off" || v === "false" || v === "0") return false;
|
|
190
|
+
console.error(`[agent-sh] Warning: ${key}="${raw}" is not a boolean (off|on|true|false|0|1); ignoring.`);
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function parseUnitFloatEnv(raw: string | undefined, key: string): number | undefined {
|
|
195
|
+
if (raw === undefined) return undefined;
|
|
196
|
+
const n = Number(raw);
|
|
197
|
+
if (!Number.isFinite(n) || n < 0 || n > 1) {
|
|
198
|
+
console.error(`[agent-sh] Warning: ${key}="${raw}" is not a number in [0, 1]; ignoring.`);
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
return n;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function loadEnvOverrides(): Partial<Settings> {
|
|
205
|
+
if (envOverrides) return envOverrides;
|
|
206
|
+
const out: Partial<Settings> = {};
|
|
207
|
+
const ac = parseBoolEnv(process.env.AGENT_SH_AUTO_COMPACT, "AGENT_SH_AUTO_COMPACT");
|
|
208
|
+
if (ac !== undefined) out.autoCompact = ac;
|
|
209
|
+
const th = parseUnitFloatEnv(process.env.AGENT_SH_AUTO_COMPACT_THRESHOLD, "AGENT_SH_AUTO_COMPACT_THRESHOLD");
|
|
210
|
+
if (th !== undefined) out.autoCompactThreshold = th;
|
|
211
|
+
envOverrides = out;
|
|
212
|
+
return envOverrides;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Load settings from disk (cached after first call). */
|
|
216
|
+
export function getSettings(): Settings & typeof DEFAULTS {
|
|
217
|
+
if (!cached) {
|
|
218
|
+
try {
|
|
219
|
+
const raw = fs.readFileSync(SETTINGS_PATH, "utf-8");
|
|
220
|
+
cached = JSON.parse(raw) as Settings;
|
|
221
|
+
} catch (err) {
|
|
222
|
+
if (err instanceof SyntaxError) {
|
|
223
|
+
console.error(`[agent-sh] Warning: invalid JSON in ${SETTINGS_PATH}: ${err.message}`);
|
|
224
|
+
}
|
|
225
|
+
cached = {};
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return { ...DEFAULTS, ...cached, ...loadEnvOverrides(), ...sessionOverlay };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function setSessionOverlay(patch: Partial<Settings>): void {
|
|
232
|
+
sessionOverlay = { ...sessionOverlay, ...patch };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function clearSessionOverlay(...keys: (keyof Settings)[]): void {
|
|
236
|
+
if (keys.length === 0) {
|
|
237
|
+
sessionOverlay = {};
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
const next = { ...sessionOverlay };
|
|
241
|
+
for (const k of keys) delete next[k];
|
|
242
|
+
sessionOverlay = next;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function getSettingSource(key: keyof Settings): SettingSource {
|
|
246
|
+
if (key in sessionOverlay) return "session";
|
|
247
|
+
if (key in loadEnvOverrides()) return "env";
|
|
248
|
+
if (cached && key in cached) return "file";
|
|
249
|
+
return "default";
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Get settings for an extension, namespaced under its key in settings.json.
|
|
254
|
+
*
|
|
255
|
+
* Example settings.json:
|
|
256
|
+
* { "latex-images": { "dpi": 600, "fgColor": "ffffff" } }
|
|
257
|
+
*
|
|
258
|
+
* Usage in extension:
|
|
259
|
+
* const config = getExtensionSettings("latex-images", { dpi: 300, fgColor: "d4d4d4" });
|
|
260
|
+
* // config.dpi === 600 (overridden), config.fgColor === "ffffff" (overridden)
|
|
261
|
+
*/
|
|
262
|
+
export function getExtensionSettings<T extends Record<string, unknown>>(
|
|
263
|
+
namespace: string,
|
|
264
|
+
defaults: T,
|
|
265
|
+
): T {
|
|
266
|
+
const all = getSettings() as unknown as Record<string, unknown>;
|
|
267
|
+
const ext = all[namespace];
|
|
268
|
+
if (ext && typeof ext === "object" && !Array.isArray(ext)) {
|
|
269
|
+
return { ...defaults, ...(ext as Partial<T>) };
|
|
270
|
+
}
|
|
271
|
+
return defaults;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Reset cached settings (for testing or after external edit). */
|
|
275
|
+
export function reloadSettings(): void {
|
|
276
|
+
cached = null;
|
|
277
|
+
envOverrides = null;
|
|
278
|
+
sessionOverlay = {};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Deep-merge a patch into ~/.agent-sh/settings.json on disk.
|
|
283
|
+
*
|
|
284
|
+
* Reads the raw file (preserving unknown keys), merges the patch, writes back
|
|
285
|
+
* with 2-space indentation, and clears the cache so subsequent getSettings()
|
|
286
|
+
* calls see the new values.
|
|
287
|
+
*
|
|
288
|
+
* Used by runtime controls (`/model`, `/backend`) that want their selection
|
|
289
|
+
* to persist as the default across restarts.
|
|
290
|
+
*/
|
|
291
|
+
export function updateSettings(patch: Record<string, unknown>): void {
|
|
292
|
+
let existing: Record<string, unknown> = {};
|
|
293
|
+
try {
|
|
294
|
+
const raw = fs.readFileSync(SETTINGS_PATH, "utf-8");
|
|
295
|
+
existing = JSON.parse(raw) as Record<string, unknown>;
|
|
296
|
+
} catch {
|
|
297
|
+
// file missing or unreadable — start fresh
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const merged = deepMerge(existing, patch);
|
|
301
|
+
|
|
302
|
+
try {
|
|
303
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
304
|
+
fs.writeFileSync(SETTINGS_PATH, JSON.stringify(merged, null, 2) + "\n", "utf-8");
|
|
305
|
+
cached = null;
|
|
306
|
+
} catch (err) {
|
|
307
|
+
console.error(`[agent-sh] Warning: failed to update ${SETTINGS_PATH}: ${(err as Error).message}`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function deepMerge(target: Record<string, unknown>, source: Record<string, unknown>): Record<string, unknown> {
|
|
312
|
+
const out: Record<string, unknown> = { ...target };
|
|
313
|
+
for (const [key, val] of Object.entries(source)) {
|
|
314
|
+
const existing = out[key];
|
|
315
|
+
if (
|
|
316
|
+
val !== null && typeof val === "object" && !Array.isArray(val) &&
|
|
317
|
+
existing !== null && typeof existing === "object" && !Array.isArray(existing)
|
|
318
|
+
) {
|
|
319
|
+
out[key] = deepMerge(existing as Record<string, unknown>, val as Record<string, unknown>);
|
|
320
|
+
} else {
|
|
321
|
+
out[key] = val;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return out;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Expand $ENV_VAR references in a string.
|
|
329
|
+
* Supports $VAR and ${VAR} syntax.
|
|
330
|
+
*/
|
|
331
|
+
export function expandEnvVars(value: string): string {
|
|
332
|
+
return value.replace(/\$\{([^}]+)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (_, braced, plain) => {
|
|
333
|
+
const name = braced || plain;
|
|
334
|
+
return process.env[name] ?? "";
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Resolved provider ready for use (env vars expanded, defaults applied). */
|
|
339
|
+
export interface ResolvedProvider {
|
|
340
|
+
id: string;
|
|
341
|
+
apiKey?: string;
|
|
342
|
+
baseURL?: string;
|
|
343
|
+
defaultModel?: string;
|
|
344
|
+
models: string[];
|
|
345
|
+
/** User explicitly listed `models` (locks the catalog to that list). */
|
|
346
|
+
modelsExplicit: boolean;
|
|
347
|
+
contextWindow?: number;
|
|
348
|
+
/** Provider supports the reasoning_effort parameter. Default: true. */
|
|
349
|
+
supportsReasoningEffort?: boolean;
|
|
350
|
+
/** Per-model capabilities, keyed by model id. */
|
|
351
|
+
modelCapabilities?: Map<string, { reasoning?: boolean; contextWindow?: number; maxTokens?: number; echoReasoning?: boolean; modalities?: ("text" | "image")[] }>;
|
|
352
|
+
/** Borrow another registered provider's reasoning request shape by id. */
|
|
353
|
+
reasoningShape?: string;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Resolve a provider config by name from settings.
|
|
358
|
+
* Returns null if provider not found.
|
|
359
|
+
*/
|
|
360
|
+
export function resolveProvider(name: string): ResolvedProvider | null {
|
|
361
|
+
const settings = getSettings();
|
|
362
|
+
const provider = settings.providers?.[name];
|
|
363
|
+
if (!provider) return null;
|
|
364
|
+
|
|
365
|
+
const rawModels = provider.models ?? (provider.defaultModel ? [provider.defaultModel] : []);
|
|
366
|
+
const modelIds: string[] = [];
|
|
367
|
+
const caps = new Map<string, { reasoning?: boolean; contextWindow?: number; maxTokens?: number; echoReasoning?: boolean; modalities?: ("text" | "image")[] }>();
|
|
368
|
+
for (const m of rawModels) {
|
|
369
|
+
if (typeof m === "string") {
|
|
370
|
+
modelIds.push(m);
|
|
371
|
+
} else {
|
|
372
|
+
modelIds.push(m.id);
|
|
373
|
+
if (m.reasoning !== undefined || m.contextWindow !== undefined || m.maxTokens !== undefined || m.echoReasoning !== undefined || m.modalities !== undefined) {
|
|
374
|
+
caps.set(m.id, { reasoning: m.reasoning, contextWindow: m.contextWindow, maxTokens: m.maxTokens, echoReasoning: m.echoReasoning, modalities: m.modalities });
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const defaultModel = provider.defaultModel ?? modelIds[0];
|
|
380
|
+
|
|
381
|
+
return {
|
|
382
|
+
id: name,
|
|
383
|
+
apiKey: provider.apiKey ? expandEnvVars(provider.apiKey) : undefined,
|
|
384
|
+
baseURL: provider.baseURL,
|
|
385
|
+
defaultModel,
|
|
386
|
+
models: modelIds.length ? modelIds : (defaultModel ? [defaultModel] : []),
|
|
387
|
+
modelsExplicit: Array.isArray(provider.models),
|
|
388
|
+
contextWindow: provider.contextWindow,
|
|
389
|
+
modelCapabilities: caps.size > 0 ? caps : undefined,
|
|
390
|
+
reasoningShape: provider.reasoningShape,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Get all configured provider names. */
|
|
395
|
+
export function getProviderNames(): string[] {
|
|
396
|
+
const settings = getSettings();
|
|
397
|
+
return Object.keys(settings.providers ?? {});
|
|
398
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { EventBus } from "./event-bus.js";
|
|
2
|
+
|
|
3
|
+
export type { ContentBlock } from "./event-bus.js";
|
|
4
|
+
|
|
5
|
+
// ── Core extension context ────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The substrate context — what every backend and every host always
|
|
9
|
+
* provides. Bus, handler registry, lifecycle, and per-instance storage.
|
|
10
|
+
*
|
|
11
|
+
* Hosts (agent, shell, web bridge, …) extend this with their own
|
|
12
|
+
* surfaces — see src/agent/host-types.ts and src/shell/host-types.ts.
|
|
13
|
+
* Extensions that only need the substrate should type their ctx as
|
|
14
|
+
* `CoreContext`; those that need host facilities should type as
|
|
15
|
+
* `AgentContext` or `ShellContext` to make their host dependency
|
|
16
|
+
* explicit (and catch misuse under bridge backends at the type level).
|
|
17
|
+
*/
|
|
18
|
+
export interface CoreContext {
|
|
19
|
+
bus: EventBus;
|
|
20
|
+
/** Stable per-instance identifier (4-char hex). */
|
|
21
|
+
readonly instanceId: string;
|
|
22
|
+
quit: () => void;
|
|
23
|
+
|
|
24
|
+
/** Read extension-namespaced settings from ~/.agent-sh/settings.json. */
|
|
25
|
+
getExtensionSettings: <T extends Record<string, unknown>>(namespace: string, defaults: T) => T;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Get (and lazily create) a per-extension storage directory under
|
|
29
|
+
* ~/.agent-sh/<namespace>/. Returns the absolute path. Lets extensions
|
|
30
|
+
* persist state without each one re-deriving the location.
|
|
31
|
+
*/
|
|
32
|
+
getStoragePath: (namespace: string) => string;
|
|
33
|
+
|
|
34
|
+
// ── Named handler registry (Emacs-style advice) ───────────
|
|
35
|
+
/** Register a named handler. */
|
|
36
|
+
define: (name: string, fn: (...args: any[]) => any) => void;
|
|
37
|
+
/** Wrap a named handler. Receives `next` (original) + args. Returns an unadvise function. */
|
|
38
|
+
advise: (name: string, wrapper: (next: (...args: any[]) => any, ...args: any[]) => any) => () => void;
|
|
39
|
+
/** Call a named handler. */
|
|
40
|
+
call: (name: string, ...args: any[]) => any;
|
|
41
|
+
/** Names of all registered handlers — for diagnostic / introspection use. */
|
|
42
|
+
list: () => string[];
|
|
43
|
+
|
|
44
|
+
/** Teardown callback fired on /reload. For resources the scoped context
|
|
45
|
+
* can't track: process listeners, timers, watchers, sockets. */
|
|
46
|
+
onDispose: (fn: () => void) => void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Core config ───────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The substrate config — kernel-level options. Hosts extend with their
|
|
53
|
+
* own surfaces (see AgentConfig in src/agent/host-types.ts and
|
|
54
|
+
* ShellConfig in src/shell/host-types.ts).
|
|
55
|
+
*/
|
|
56
|
+
export interface CoreConfig {
|
|
57
|
+
/** Extension specifiers (paths or package names) to load on startup. */
|
|
58
|
+
extensions?: string[];
|
|
59
|
+
/** Override settings.defaultBackend for this session only (does not persist). */
|
|
60
|
+
backend?: string;
|
|
61
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File autocomplete extension.
|
|
3
|
+
*
|
|
4
|
+
* Provides @-triggered file path completion in agent input mode.
|
|
5
|
+
* Responds to "autocomplete:request" pipe events by listing files
|
|
6
|
+
* matching the path after the @ trigger.
|
|
7
|
+
*/
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import type { ExtensionContext } from "../shell/host-types.js";
|
|
11
|
+
|
|
12
|
+
export default function activate(ctx: ExtensionContext): void {
|
|
13
|
+
ctx.bus.onPipe("autocomplete:request", (payload) => {
|
|
14
|
+
const atPos = payload.buffer.lastIndexOf("@");
|
|
15
|
+
if (atPos < 0 || (atPos > 0 && payload.buffer[atPos - 1] !== " ")) {
|
|
16
|
+
return payload;
|
|
17
|
+
}
|
|
18
|
+
const afterAt = payload.buffer.slice(atPos + 1);
|
|
19
|
+
if (afterAt.includes(" ") || !/^[a-zA-Z0-9_.\/-]*$/.test(afterAt)) {
|
|
20
|
+
return payload;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const files = listFiles(afterAt, ctx.call("cwd") as string);
|
|
24
|
+
if (files.length === 0) return payload;
|
|
25
|
+
return { ...payload, items: [...payload.items, ...files] };
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function listFiles(
|
|
30
|
+
query: string,
|
|
31
|
+
cwd: string,
|
|
32
|
+
): { name: string; description: string }[] {
|
|
33
|
+
const lastSlash = query.lastIndexOf("/");
|
|
34
|
+
let searchDir: string;
|
|
35
|
+
let prefix: string;
|
|
36
|
+
let basePath: string;
|
|
37
|
+
|
|
38
|
+
if (lastSlash >= 0) {
|
|
39
|
+
basePath = query.slice(0, lastSlash + 1);
|
|
40
|
+
searchDir = path.resolve(cwd, query.slice(0, lastSlash) || ".");
|
|
41
|
+
prefix = query.slice(lastSlash + 1);
|
|
42
|
+
} else {
|
|
43
|
+
basePath = "";
|
|
44
|
+
searchDir = cwd;
|
|
45
|
+
prefix = query;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let entries: fs.Dirent[];
|
|
49
|
+
try {
|
|
50
|
+
entries = fs.readdirSync(searchDir, { withFileTypes: true });
|
|
51
|
+
} catch {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return entries
|
|
56
|
+
.filter(
|
|
57
|
+
(e) =>
|
|
58
|
+
!e.name.startsWith(".") &&
|
|
59
|
+
e.name.toLowerCase().startsWith(prefix.toLowerCase()),
|
|
60
|
+
)
|
|
61
|
+
.sort((a, b) => {
|
|
62
|
+
if (a.isDirectory() && !b.isDirectory()) return -1;
|
|
63
|
+
if (!a.isDirectory() && b.isDirectory()) return 1;
|
|
64
|
+
return a.name.localeCompare(b.name);
|
|
65
|
+
})
|
|
66
|
+
.slice(0, 15)
|
|
67
|
+
.map((e) => ({
|
|
68
|
+
name: basePath + e.name + (e.isDirectory() ? "/" : ""),
|
|
69
|
+
description: e.isDirectory() ? "dir" : "",
|
|
70
|
+
}));
|
|
71
|
+
}
|