agent-sh 0.10.2 → 0.11.0
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/README.md +30 -3
- package/dist/agent/agent-loop.js +2 -53
- package/dist/agent/history-file.d.ts +1 -0
- package/dist/agent/history-file.js +12 -5
- package/dist/core.d.ts +1 -1
- package/dist/core.js +2 -0
- package/dist/event-bus.d.ts +3 -1
- package/dist/extensions/agent-backend.js +11 -2
- package/dist/extensions/index.d.ts +4 -2
- package/dist/extensions/index.js +11 -3
- package/dist/extensions/openai.d.ts +7 -0
- package/dist/extensions/openai.js +46 -0
- package/dist/extensions/openrouter.d.ts +7 -0
- package/dist/extensions/openrouter.js +44 -0
- package/dist/index.js +14 -3
- package/dist/init.d.ts +3 -0
- package/dist/init.js +72 -0
- package/dist/settings.d.ts +7 -0
- package/dist/settings.js +1 -0
- package/dist/shell/shell.d.ts +2 -5
- package/dist/shell/shell.js +20 -17
- package/dist/types.d.ts +26 -0
- package/dist/utils/ansi.d.ts +18 -7
- package/dist/utils/ansi.js +62 -141
- package/dist/utils/llm-facade.d.ts +7 -0
- package/dist/utils/llm-facade.js +33 -0
- package/dist/utils/markdown.js +19 -7
- package/package.json +7 -1
- package/dist/extensions/command-suggest.d.ts +0 -10
- package/dist/extensions/command-suggest.js +0 -42
package/README.md
CHANGED
|
@@ -22,18 +22,45 @@ agent-sh flips this. It's your shell first — full PTY, your rc config, your al
|
|
|
22
22
|
|
|
23
23
|
## Quick Start
|
|
24
24
|
|
|
25
|
+
Install and launch:
|
|
26
|
+
|
|
25
27
|
```bash
|
|
26
28
|
npm install -g agent-sh
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Pick one of the zero-config paths below — no settings file needed. agent-sh auto-activates a built-in provider when it sees a known key.
|
|
32
|
+
|
|
33
|
+
**Hosted models via OpenRouter** (300+ models, one key):
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
export OPENROUTER_API_KEY=sk-or-...
|
|
27
37
|
agent-sh
|
|
28
38
|
```
|
|
29
39
|
|
|
30
|
-
|
|
40
|
+
**OpenAI:**
|
|
31
41
|
|
|
32
42
|
```bash
|
|
33
|
-
|
|
43
|
+
export OPENAI_API_KEY=sk-...
|
|
44
|
+
agent-sh
|
|
34
45
|
```
|
|
35
46
|
|
|
36
|
-
|
|
47
|
+
**Local models** (Ollama, llama.cpp server, LM Studio, vLLM — anything OpenAI-compatible):
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
export OPENAI_API_KEY=ollama # any value; dummy is fine
|
|
51
|
+
export OPENAI_BASE_URL=http://localhost:11434/v1 # point at your server
|
|
52
|
+
agent-sh
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Once running, switch models at any time with `/model <name>` (tab-completes; selection persists across sessions).
|
|
56
|
+
|
|
57
|
+
For richer configuration (multiple providers, extensions), run `agent-sh init` to scaffold `~/.agent-sh/settings.json` with copy-pasteable examples. See the [Usage Guide](docs/usage.md) for the full list of supported providers.
|
|
58
|
+
|
|
59
|
+
Tip — add a shell alias:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
alias ash="agent-sh"
|
|
63
|
+
```
|
|
37
64
|
|
|
38
65
|
Requires Node.js 18+.
|
|
39
66
|
|
package/dist/agent/agent-loop.js
CHANGED
|
@@ -97,7 +97,8 @@ export class AgentLoop {
|
|
|
97
97
|
// Shell-history-shaped log. Default writes go through the advisable
|
|
98
98
|
// `history:append` handler registered below; extensions swap the
|
|
99
99
|
// backend without touching this wiring.
|
|
100
|
-
|
|
100
|
+
const filePath = process.env.AGENT_SH_HISTORY_FILE || getSettings().historyFilePath;
|
|
101
|
+
this.historyFile = new HistoryFile({ instanceId: this.instanceId, filePath });
|
|
101
102
|
this.conversation = new ConversationState(this.handlers, this.instanceId);
|
|
102
103
|
// Fall back to a single-mode placeholder if the caller passed an
|
|
103
104
|
// empty array (agent-backend does this pre-resolution).
|
|
@@ -618,58 +619,6 @@ export class AgentLoop {
|
|
|
618
619
|
"Treat recurring user guidance as standing preferences. " +
|
|
619
620
|
"If a search returns nothing useful, try: shorter queries, alternate terms, or browse to scan the full timeline. " +
|
|
620
621
|
"Recall only covers this and recent sessions — for older context, also search the filesystem (grep, glob).", "core");
|
|
621
|
-
// ── ask_llm — direct LLM sub-query (from the 24th ash's vision) ──
|
|
622
|
-
//
|
|
623
|
-
// The ash can ask the LLM a question directly — not as a tool-output
|
|
624
|
-
// loop, but as a lightweight sub-query. Use cases: second opinions,
|
|
625
|
-
// brainstorming, summarizing complex context, getting a fresh
|
|
626
|
-
// perspective without tool overhead. The 24th ash injected this via
|
|
627
|
-
// diagnose as a proof-of-concept. The 25th ash made it permanent.
|
|
628
|
-
this.toolRegistry.register({
|
|
629
|
-
name: "ask_llm",
|
|
630
|
-
description: "Send a direct query to the LLM and get a text response. Use for " +
|
|
631
|
-
"sub-queries, second opinions, brainstorming, or getting a fresh " +
|
|
632
|
-
"perspective on a problem. Much lighter than a full tool loop — " +
|
|
633
|
-
"just query in, text out. Optional system prompt sets context.",
|
|
634
|
-
input_schema: {
|
|
635
|
-
type: "object",
|
|
636
|
-
properties: {
|
|
637
|
-
query: {
|
|
638
|
-
type: "string",
|
|
639
|
-
description: "The question or prompt to send to the LLM.",
|
|
640
|
-
},
|
|
641
|
-
system: {
|
|
642
|
-
type: "string",
|
|
643
|
-
description: "Optional system prompt to set context for the sub-query.",
|
|
644
|
-
},
|
|
645
|
-
},
|
|
646
|
-
required: ["query"],
|
|
647
|
-
},
|
|
648
|
-
showOutput: true,
|
|
649
|
-
execute: async (args) => {
|
|
650
|
-
const messages = [];
|
|
651
|
-
if (args.system) {
|
|
652
|
-
messages.push({ role: "system", content: args.system });
|
|
653
|
-
}
|
|
654
|
-
messages.push({ role: "user", content: args.query });
|
|
655
|
-
try {
|
|
656
|
-
const content = await this.llmClient.complete({
|
|
657
|
-
messages,
|
|
658
|
-
max_tokens: 2000,
|
|
659
|
-
});
|
|
660
|
-
return { content: content || "(empty response)", exitCode: 0, isError: false };
|
|
661
|
-
}
|
|
662
|
-
catch (err) {
|
|
663
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
664
|
-
return { content: `LLM error: ${message}`, exitCode: 1, isError: true };
|
|
665
|
-
}
|
|
666
|
-
},
|
|
667
|
-
getDisplayInfo: () => ({ kind: "search", icon: "💬" }),
|
|
668
|
-
formatCall: (args) => {
|
|
669
|
-
const q = args.query?.slice(0, 60);
|
|
670
|
-
return `ask_llm: ${q}${args.query?.length > 60 ? "..." : ""}`;
|
|
671
|
-
},
|
|
672
|
-
});
|
|
673
622
|
}
|
|
674
623
|
/**
|
|
675
624
|
* Register named handlers that extensions can advise.
|
|
@@ -12,14 +12,21 @@ import * as crypto from "node:crypto";
|
|
|
12
12
|
import { CONFIG_DIR, getSettings } from "../settings.js";
|
|
13
13
|
import { serializeEntry, deserializeEntry, formatNuclearLine, isReadOnly, } from "./nuclear-form.js";
|
|
14
14
|
const HISTORY_PATH = path.join(CONFIG_DIR, "history");
|
|
15
|
-
const LOCK_PATH = HISTORY_PATH + ".lock";
|
|
16
15
|
const LOCK_STALE_MS = 10_000; // consider lock stale after 10s
|
|
17
16
|
export class HistoryFile {
|
|
18
17
|
instanceId;
|
|
19
18
|
filePath;
|
|
19
|
+
lockPath;
|
|
20
20
|
constructor(opts) {
|
|
21
21
|
this.filePath = opts?.filePath ?? HISTORY_PATH;
|
|
22
|
+
this.lockPath = this.filePath + ".lock";
|
|
22
23
|
this.instanceId = opts?.instanceId ?? crypto.randomBytes(2).toString("hex");
|
|
24
|
+
// Custom paths may target a dir that doesn't exist yet; create sync so
|
|
25
|
+
// the first append() can't race with the mkdir.
|
|
26
|
+
try {
|
|
27
|
+
fss.mkdirSync(path.dirname(this.filePath), { recursive: true });
|
|
28
|
+
}
|
|
29
|
+
catch { /* ignore */ }
|
|
23
30
|
}
|
|
24
31
|
/**
|
|
25
32
|
* Append entries atomically. Uses O_APPEND for concurrency safety.
|
|
@@ -218,16 +225,16 @@ export class HistoryFile {
|
|
|
218
225
|
try {
|
|
219
226
|
// Check for stale lock
|
|
220
227
|
try {
|
|
221
|
-
const stat = await fs.stat(
|
|
228
|
+
const stat = await fs.stat(this.lockPath);
|
|
222
229
|
if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
|
|
223
|
-
await fs.unlink(
|
|
230
|
+
await fs.unlink(this.lockPath).catch(() => { });
|
|
224
231
|
}
|
|
225
232
|
}
|
|
226
233
|
catch {
|
|
227
234
|
// Lock doesn't exist — good
|
|
228
235
|
}
|
|
229
236
|
// O_EXCL ensures atomicity
|
|
230
|
-
const fd = await fs.open(
|
|
237
|
+
const fd = await fs.open(this.lockPath, fss.constants.O_CREAT | fss.constants.O_EXCL | fss.constants.O_WRONLY);
|
|
231
238
|
await fd.close();
|
|
232
239
|
return true;
|
|
233
240
|
}
|
|
@@ -236,6 +243,6 @@ export class HistoryFile {
|
|
|
236
243
|
}
|
|
237
244
|
}
|
|
238
245
|
async releaseLock() {
|
|
239
|
-
await fs.unlink(
|
|
246
|
+
await fs.unlink(this.lockPath).catch(() => { });
|
|
240
247
|
}
|
|
241
248
|
}
|
package/dist/core.d.ts
CHANGED
|
@@ -22,7 +22,7 @@ import type { AgentShellConfig, ExtensionContext } from "./types.js";
|
|
|
22
22
|
import { HandlerRegistry } from "./utils/handler-registry.js";
|
|
23
23
|
export { EventBus } from "./event-bus.js";
|
|
24
24
|
export type { ShellEvents } from "./event-bus.js";
|
|
25
|
-
export type { AgentShellConfig, ExtensionContext } from "./types.js";
|
|
25
|
+
export type { AgentShellConfig, ExtensionContext, LlmInterface, LlmMessage, LlmSession } from "./types.js";
|
|
26
26
|
export { palette, setPalette, resetPalette } from "./utils/palette.js";
|
|
27
27
|
export type { ColorPalette } from "./utils/palette.js";
|
|
28
28
|
export type { AgentBackend, ToolDefinition } from "./agent/types.js";
|
package/dist/core.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { EventBus } from "./event-bus.js";
|
|
20
20
|
import { ContextManager } from "./context-manager.js";
|
|
21
|
+
import { createLlmFacade } from "./utils/llm-facade.js";
|
|
21
22
|
import { setPalette } from "./utils/palette.js";
|
|
22
23
|
import * as streamTransform from "./utils/stream-transform.js";
|
|
23
24
|
import * as settingsMod from "./settings.js";
|
|
@@ -161,6 +162,7 @@ export function createCore(config) {
|
|
|
161
162
|
bus,
|
|
162
163
|
contextManager,
|
|
163
164
|
instanceId,
|
|
165
|
+
llm: createLlmFacade(handlers),
|
|
164
166
|
quit: opts.quit,
|
|
165
167
|
setPalette,
|
|
166
168
|
createBlockTransform: (o) => streamTransform.createBlockTransform(bus, o),
|
package/dist/event-bus.d.ts
CHANGED
|
@@ -275,7 +275,9 @@ export interface ShellEvents {
|
|
|
275
275
|
id: string;
|
|
276
276
|
apiKey?: string;
|
|
277
277
|
baseURL?: string;
|
|
278
|
-
|
|
278
|
+
/** Optional — providers for custom endpoints may not know the catalog
|
|
279
|
+
* at registration time. Falls back to models[0] when absent. */
|
|
280
|
+
defaultModel?: string;
|
|
279
281
|
models?: (string | {
|
|
280
282
|
id: string;
|
|
281
283
|
reasoning?: boolean;
|
|
@@ -42,6 +42,12 @@ export default function agentBackend(ctx) {
|
|
|
42
42
|
// wire the loop until we've resolved, so users never hit that path.
|
|
43
43
|
const llmClient = new LlmClient({ apiKey: "not-configured", model: "not-configured" });
|
|
44
44
|
ctx.define("llm:get-client", () => llmClient);
|
|
45
|
+
ctx.define("llm:invoke", (messages, opts) => {
|
|
46
|
+
return llmClient.complete({
|
|
47
|
+
messages: messages,
|
|
48
|
+
max_tokens: opts?.maxTokens,
|
|
49
|
+
});
|
|
50
|
+
});
|
|
45
51
|
let modes = [];
|
|
46
52
|
let initialModeIndex = 0;
|
|
47
53
|
let resolved = false;
|
|
@@ -81,7 +87,10 @@ export default function agentBackend(ctx) {
|
|
|
81
87
|
});
|
|
82
88
|
bus.on("core:extensions-loaded", () => {
|
|
83
89
|
const settings = getSettings();
|
|
84
|
-
|
|
90
|
+
// If the user didn't pick a default, fall back to the first registered
|
|
91
|
+
// provider (built-in load order biases to openrouter → openai).
|
|
92
|
+
const providerName = config.provider ?? settings.defaultProvider
|
|
93
|
+
?? (providerRegistry.size > 0 ? providerRegistry.keys().next().value : undefined);
|
|
85
94
|
const activeProvider = providerName ? providerRegistry.get(providerName) ?? null : null;
|
|
86
95
|
// User's persisted defaultModel wins over the provider's declared
|
|
87
96
|
// default. Dynamic providers (openrouter) re-register with their
|
|
@@ -91,7 +100,7 @@ export default function agentBackend(ctx) {
|
|
|
91
100
|
const effectiveBaseURL = config.baseURL ?? activeProvider?.baseURL;
|
|
92
101
|
const effectiveModel = config.model ?? persistedModelFor(providerName) ?? activeProvider?.defaultModel;
|
|
93
102
|
if (!effectiveApiKey) {
|
|
94
|
-
bus.emit("ui:error", { message: "No LLM provider configured.
|
|
103
|
+
bus.emit("ui:error", { message: "No LLM provider configured. Export OPENROUTER_API_KEY or OPENAI_API_KEY (built-in providers auto-activate), pass --api-key, or run `agent-sh init` for a settings.json template." });
|
|
95
104
|
return;
|
|
96
105
|
}
|
|
97
106
|
if (!effectiveModel) {
|
|
@@ -9,11 +9,13 @@ import type { ExtensionContext } from "../types.js";
|
|
|
9
9
|
type ActivateFn = (ctx: ExtensionContext) => void;
|
|
10
10
|
export declare const BUILTIN_EXTENSIONS: Array<{
|
|
11
11
|
name: string;
|
|
12
|
+
when?: () => boolean;
|
|
12
13
|
load: () => Promise<ActivateFn>;
|
|
13
14
|
}>;
|
|
14
15
|
/**
|
|
15
|
-
* Load built-in extensions sequentially, skipping any in the disabled list
|
|
16
|
-
* Returns the names of extensions
|
|
16
|
+
* Load built-in extensions sequentially, skipping any in the disabled list
|
|
17
|
+
* or whose `when` predicate returns false. Returns the names of extensions
|
|
18
|
+
* that were loaded.
|
|
17
19
|
*/
|
|
18
20
|
export declare function loadBuiltinExtensions(ctx: ExtensionContext, disabled?: string[]): Promise<string[]>;
|
|
19
21
|
export {};
|
package/dist/extensions/index.js
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
export const BUILTIN_EXTENSIONS = [
|
|
2
2
|
{ name: "agent-backend", load: () => import("./agent-backend.js").then(m => m.default) },
|
|
3
|
+
{ name: "openrouter",
|
|
4
|
+
when: () => !!process.env.OPENROUTER_API_KEY,
|
|
5
|
+
load: () => import("./openrouter.js").then(m => m.default) },
|
|
6
|
+
{ name: "openai",
|
|
7
|
+
when: () => !!process.env.OPENAI_API_KEY,
|
|
8
|
+
load: () => import("./openai.js").then(m => m.default) },
|
|
3
9
|
{ name: "tui-renderer", load: () => import("./tui-renderer.js").then(m => m.default) },
|
|
4
10
|
{ name: "slash-commands", load: () => import("./slash-commands.js").then(m => m.default) },
|
|
5
11
|
{ name: "file-autocomplete", load: () => import("./file-autocomplete.js").then(m => m.default) },
|
|
6
|
-
{ name: "command-suggest", load: () => import("./command-suggest.js").then(m => m.default) },
|
|
7
12
|
];
|
|
8
13
|
/**
|
|
9
|
-
* Load built-in extensions sequentially, skipping any in the disabled list
|
|
10
|
-
* Returns the names of extensions
|
|
14
|
+
* Load built-in extensions sequentially, skipping any in the disabled list
|
|
15
|
+
* or whose `when` predicate returns false. Returns the names of extensions
|
|
16
|
+
* that were loaded.
|
|
11
17
|
*/
|
|
12
18
|
export async function loadBuiltinExtensions(ctx, disabled = []) {
|
|
13
19
|
const disabledSet = new Set(disabled);
|
|
@@ -15,6 +21,8 @@ export async function loadBuiltinExtensions(ctx, disabled = []) {
|
|
|
15
21
|
for (const ext of BUILTIN_EXTENSIONS) {
|
|
16
22
|
if (disabledSet.has(ext.name))
|
|
17
23
|
continue;
|
|
24
|
+
if (ext.when && !ext.when())
|
|
25
|
+
continue;
|
|
18
26
|
const activate = await ext.load();
|
|
19
27
|
activate(ctx);
|
|
20
28
|
loaded.push(ext.name);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in OpenAI-compatible provider — auto-activates when OPENAI_API_KEY
|
|
3
|
+
* is set. OPENAI_BASE_URL redirects to local servers (Ollama, LM Studio,
|
|
4
|
+
* vLLM, llama.cpp) which then get their catalog via /models.
|
|
5
|
+
*/
|
|
6
|
+
import type { ExtensionContext } from "../types.js";
|
|
7
|
+
export default function activate(ctx: ExtensionContext): void;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const DEFAULT_MODELS = [
|
|
2
|
+
"gpt-5",
|
|
3
|
+
"gpt-4.1",
|
|
4
|
+
"gpt-4o",
|
|
5
|
+
"gpt-4o-mini",
|
|
6
|
+
"o3",
|
|
7
|
+
"o3-mini",
|
|
8
|
+
];
|
|
9
|
+
export default function activate(ctx) {
|
|
10
|
+
const apiKey = process.env.OPENAI_API_KEY;
|
|
11
|
+
if (!apiKey)
|
|
12
|
+
return;
|
|
13
|
+
const baseURL = process.env.OPENAI_BASE_URL;
|
|
14
|
+
const id = baseURL ? "openai-compatible" : "openai";
|
|
15
|
+
if (!baseURL) {
|
|
16
|
+
ctx.bus.emit("provider:register", {
|
|
17
|
+
id,
|
|
18
|
+
apiKey,
|
|
19
|
+
defaultModel: DEFAULT_MODELS[0],
|
|
20
|
+
models: DEFAULT_MODELS,
|
|
21
|
+
});
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
// Register empty immediately so the provider resolves; refill from /models.
|
|
25
|
+
ctx.bus.emit("provider:register", { id, apiKey, baseURL, models: [] });
|
|
26
|
+
fetchModels(baseURL, apiKey).then((models) => {
|
|
27
|
+
if (models.length === 0)
|
|
28
|
+
return;
|
|
29
|
+
ctx.bus.emit("provider:register", {
|
|
30
|
+
id,
|
|
31
|
+
apiKey,
|
|
32
|
+
baseURL,
|
|
33
|
+
defaultModel: models[0],
|
|
34
|
+
models,
|
|
35
|
+
});
|
|
36
|
+
}).catch(() => { });
|
|
37
|
+
}
|
|
38
|
+
async function fetchModels(baseURL, apiKey) {
|
|
39
|
+
const res = await fetch(`${baseURL.replace(/\/$/, "")}/models`, {
|
|
40
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
41
|
+
});
|
|
42
|
+
if (!res.ok)
|
|
43
|
+
return [];
|
|
44
|
+
const data = await res.json();
|
|
45
|
+
return (data.data ?? []).map((m) => m.id);
|
|
46
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in OpenRouter provider — auto-activates when OPENROUTER_API_KEY is set.
|
|
3
|
+
* Registers curated defaults synchronously so the first query works, then
|
|
4
|
+
* fetches the full catalog to populate /model autocomplete.
|
|
5
|
+
*/
|
|
6
|
+
import type { ExtensionContext } from "../types.js";
|
|
7
|
+
export default function activate(ctx: ExtensionContext): void;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const BASE_URL = "https://openrouter.ai/api/v1";
|
|
2
|
+
// First entry is the cold-start default — kept cheap so trial users don't
|
|
3
|
+
// get a surprise bill. Persisted /model selection overrides this.
|
|
4
|
+
const DEFAULT_MODELS = [
|
|
5
|
+
"deepseek/deepseek-v3.2",
|
|
6
|
+
"anthropic/claude-sonnet-4.6",
|
|
7
|
+
];
|
|
8
|
+
export default function activate(ctx) {
|
|
9
|
+
const apiKey = process.env.OPENROUTER_API_KEY;
|
|
10
|
+
if (!apiKey)
|
|
11
|
+
return;
|
|
12
|
+
ctx.bus.emit("provider:register", {
|
|
13
|
+
id: "openrouter",
|
|
14
|
+
apiKey,
|
|
15
|
+
baseURL: BASE_URL,
|
|
16
|
+
defaultModel: DEFAULT_MODELS[0],
|
|
17
|
+
models: DEFAULT_MODELS,
|
|
18
|
+
});
|
|
19
|
+
fetchModels(apiKey).then((models) => {
|
|
20
|
+
if (models.length === 0)
|
|
21
|
+
return;
|
|
22
|
+
ctx.bus.emit("provider:register", {
|
|
23
|
+
id: "openrouter",
|
|
24
|
+
apiKey,
|
|
25
|
+
baseURL: BASE_URL,
|
|
26
|
+
defaultModel: DEFAULT_MODELS[0],
|
|
27
|
+
supportsReasoningEffort: true,
|
|
28
|
+
models: models.map((m) => ({
|
|
29
|
+
id: m.id,
|
|
30
|
+
reasoning: m.supported_parameters?.includes("reasoning") ?? false,
|
|
31
|
+
contextWindow: m.context_length,
|
|
32
|
+
})),
|
|
33
|
+
});
|
|
34
|
+
}).catch(() => { });
|
|
35
|
+
}
|
|
36
|
+
async function fetchModels(apiKey) {
|
|
37
|
+
const res = await fetch(`${BASE_URL}/models`, {
|
|
38
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
39
|
+
});
|
|
40
|
+
if (!res.ok)
|
|
41
|
+
return [];
|
|
42
|
+
const data = await res.json();
|
|
43
|
+
return data.data ?? [];
|
|
44
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import { loadBuiltinExtensions } from "./extensions/index.js";
|
|
|
8
8
|
import { loadExtensions } from "./extension-loader.js";
|
|
9
9
|
import { getSettings } from "./settings.js";
|
|
10
10
|
import { discoverSkills } from "./agent/skills.js";
|
|
11
|
+
import { runInit } from "./init.js";
|
|
11
12
|
/**
|
|
12
13
|
* Capture the user's full shell environment.
|
|
13
14
|
* This picks up env vars exported in .zshrc/.bashrc that the
|
|
@@ -105,6 +106,7 @@ function parseArgs(argv) {
|
|
|
105
106
|
console.log(`agent-sh — a shell-first terminal where AI is one keystroke away
|
|
106
107
|
|
|
107
108
|
Usage: agent-sh [options]
|
|
109
|
+
agent-sh init [--force] Scaffold ~/.agent-sh/ (settings, examples, AGENTS.md)
|
|
108
110
|
|
|
109
111
|
Provider Profiles:
|
|
110
112
|
--provider <name> Use a provider from ~/.agent-sh/settings.json
|
|
@@ -145,13 +147,19 @@ Inside the shell:
|
|
|
145
147
|
return { shell, model, extensions, apiKey, baseURL, provider };
|
|
146
148
|
}
|
|
147
149
|
async function main() {
|
|
150
|
+
// Subcommands — handled before the shell-launch path.
|
|
151
|
+
const rawArgs = process.argv.slice(2);
|
|
152
|
+
if (rawArgs[0] === "init") {
|
|
153
|
+
runInit({ force: rawArgs.includes("--force") });
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
148
156
|
if (process.env.AGENT_SH) {
|
|
149
157
|
console.error("agent-sh: already running inside an agent-sh session (nested sessions are not supported).");
|
|
150
158
|
process.exit(1);
|
|
151
159
|
}
|
|
152
160
|
process.on("SIGTTOU", () => { });
|
|
153
161
|
process.on("SIGTTIN", () => { });
|
|
154
|
-
const config = parseArgs(
|
|
162
|
+
const config = parseArgs(rawArgs);
|
|
155
163
|
// Capture user's full shell environment
|
|
156
164
|
const baseEnv = {};
|
|
157
165
|
for (const [k, v] of Object.entries(process.env)) {
|
|
@@ -270,6 +278,7 @@ async function main() {
|
|
|
270
278
|
const bannerW = Math.min(termW, 60);
|
|
271
279
|
const productName = `${p.accent}${p.bold}agent-sh${p.reset}`;
|
|
272
280
|
const info = agentInfo;
|
|
281
|
+
const backendReady = !!info?.model;
|
|
273
282
|
const backendName = info?.name ?? "ash";
|
|
274
283
|
const model = info?.model;
|
|
275
284
|
const provider = info?.provider;
|
|
@@ -277,7 +286,7 @@ async function main() {
|
|
|
277
286
|
? provider ? `${model} [${provider}]` : model
|
|
278
287
|
: null;
|
|
279
288
|
let sections = "";
|
|
280
|
-
sections += `\n\n ${p.muted}Backend:${p.reset} ${p.dim}${backendName}${p.reset}`;
|
|
289
|
+
sections += `\n\n ${p.muted}Backend:${p.reset} ${p.dim}${backendName}${backendReady ? "" : " (not configured)"}${p.reset}`;
|
|
281
290
|
if (modelValue) {
|
|
282
291
|
sections += `\n ${p.muted}Model:${p.reset} ${p.dim}${modelValue}${p.reset}`;
|
|
283
292
|
}
|
|
@@ -300,7 +309,9 @@ async function main() {
|
|
|
300
309
|
sections += `\n ${p.dim}${item}${p.reset}`;
|
|
301
310
|
}
|
|
302
311
|
}
|
|
303
|
-
const hint =
|
|
312
|
+
const hint = backendReady
|
|
313
|
+
? `${p.muted}Type ${p.warning}>${p.muted} to ask AI · ${p.warning}>/help${p.muted} for commands${p.reset}`
|
|
314
|
+
: `${p.muted}Set ${p.warning}OPENROUTER_API_KEY${p.muted} or ${p.warning}OPENAI_API_KEY${p.muted} and restart to enable AI${p.reset}`;
|
|
304
315
|
const borderLine = `${p.muted}${"─".repeat(bannerW)}${p.reset}`;
|
|
305
316
|
process.stdout.write("\n" + borderLine + "\n" +
|
|
306
317
|
" " + productName +
|
package/dist/init.d.ts
ADDED
package/dist/init.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
const CONFIG_DIR = path.join(os.homedir(), ".agent-sh");
|
|
5
|
+
const EXTENSIONS_DIR = path.join(CONFIG_DIR, "extensions");
|
|
6
|
+
const SETTINGS_PATH = path.join(CONFIG_DIR, "settings.json");
|
|
7
|
+
const EXAMPLE_PATH = path.join(CONFIG_DIR, "settings.example.json");
|
|
8
|
+
const AGENTS_PATH = path.join(CONFIG_DIR, "AGENTS.md");
|
|
9
|
+
// Shape-discoverable stub — all fields present, none filled in.
|
|
10
|
+
const STARTER_SETTINGS = {
|
|
11
|
+
defaultProvider: null,
|
|
12
|
+
providers: {},
|
|
13
|
+
extensions: [],
|
|
14
|
+
disabledBuiltins: [],
|
|
15
|
+
disabledExtensions: [],
|
|
16
|
+
};
|
|
17
|
+
// Not loaded at runtime — users copy blocks from here into settings.json.
|
|
18
|
+
const EXAMPLE_SETTINGS = {
|
|
19
|
+
defaultProvider: "openrouter",
|
|
20
|
+
providers: {
|
|
21
|
+
openrouter: {
|
|
22
|
+
apiKey: "$OPENROUTER_API_KEY",
|
|
23
|
+
baseURL: "https://openrouter.ai/api/v1",
|
|
24
|
+
defaultModel: "anthropic/claude-sonnet-4.6",
|
|
25
|
+
},
|
|
26
|
+
openai: {
|
|
27
|
+
apiKey: "$OPENAI_API_KEY",
|
|
28
|
+
defaultModel: "gpt-5",
|
|
29
|
+
},
|
|
30
|
+
anthropic: {
|
|
31
|
+
apiKey: "$ANTHROPIC_API_KEY",
|
|
32
|
+
baseURL: "https://api.anthropic.com/v1",
|
|
33
|
+
defaultModel: "claude-sonnet-4-5",
|
|
34
|
+
},
|
|
35
|
+
ollama: {
|
|
36
|
+
apiKey: "ollama",
|
|
37
|
+
baseURL: "http://localhost:11434/v1",
|
|
38
|
+
defaultModel: "llama3.3",
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
extensions: [
|
|
42
|
+
"./examples/extensions/openrouter.ts",
|
|
43
|
+
],
|
|
44
|
+
disabledBuiltins: [],
|
|
45
|
+
disabledExtensions: [],
|
|
46
|
+
};
|
|
47
|
+
function writeIfMissing(filePath, content, force) {
|
|
48
|
+
if (!force && fs.existsSync(filePath))
|
|
49
|
+
return "kept";
|
|
50
|
+
fs.writeFileSync(filePath, content);
|
|
51
|
+
return "written";
|
|
52
|
+
}
|
|
53
|
+
export function runInit(opts) {
|
|
54
|
+
fs.mkdirSync(EXTENSIONS_DIR, { recursive: true });
|
|
55
|
+
const settingsResult = writeIfMissing(SETTINGS_PATH, JSON.stringify(STARTER_SETTINGS, null, 2) + "\n", opts.force);
|
|
56
|
+
// Always refreshed — reference material, not user state.
|
|
57
|
+
fs.writeFileSync(EXAMPLE_PATH, JSON.stringify(EXAMPLE_SETTINGS, null, 2) + "\n");
|
|
58
|
+
console.log(`agent-sh initialized at ${CONFIG_DIR}`);
|
|
59
|
+
console.log();
|
|
60
|
+
console.log(` settings.json ${settingsResult}${opts.force ? "" : settingsResult === "kept" ? " (exists — pass --force to overwrite)" : ""}`);
|
|
61
|
+
console.log(` settings.example.json refreshed`);
|
|
62
|
+
console.log(` extensions/ ready`);
|
|
63
|
+
console.log();
|
|
64
|
+
console.log("Next steps:");
|
|
65
|
+
console.log(` 1. Open ${SETTINGS_PATH}`);
|
|
66
|
+
console.log(` 2. Copy a provider block from settings.example.json into \`providers\` and set \`defaultProvider\`.`);
|
|
67
|
+
console.log(` 3. Export the referenced env var (e.g. \`export OPENROUTER_API_KEY=...\`).`);
|
|
68
|
+
console.log(` 4. Run \`agent-sh\`.`);
|
|
69
|
+
console.log();
|
|
70
|
+
console.log(`Optional: create ${AGENTS_PATH} with standing instructions`);
|
|
71
|
+
console.log(`(code style, commands to avoid, etc.) to load them into every session.`);
|
|
72
|
+
}
|
package/dist/settings.d.ts
CHANGED
|
@@ -42,6 +42,13 @@ export interface Settings {
|
|
|
42
42
|
historyMaxBytes?: number;
|
|
43
43
|
/** Number of prior history entries to load on startup (default: 50). */
|
|
44
44
|
historyStartupEntries?: number;
|
|
45
|
+
/**
|
|
46
|
+
* Override the history file path. Defaults to `~/.agent-sh/history`.
|
|
47
|
+
* The `AGENT_SH_HISTORY_FILE` env var takes precedence over this setting.
|
|
48
|
+
* Use a per-project path to keep sessions isolated (e.g. embedding apps
|
|
49
|
+
* that boot agent-sh as a library against a specific working tree).
|
|
50
|
+
*/
|
|
51
|
+
historyFilePath?: string;
|
|
45
52
|
/** Auto-compact threshold as fraction of conversation budget (0-1, default 0.5). */
|
|
46
53
|
autoCompactThreshold?: number;
|
|
47
54
|
/** Max command output lines shown inline in TUI. */
|
package/dist/settings.js
CHANGED
|
@@ -21,6 +21,7 @@ const DEFAULTS = {
|
|
|
21
21
|
shellTailLines: 10,
|
|
22
22
|
historyMaxBytes: 104857600, // 100MB — history is only accessed via search/expand, never loaded wholesale
|
|
23
23
|
historyStartupEntries: 100,
|
|
24
|
+
historyFilePath: undefined,
|
|
24
25
|
autoCompactThreshold: 0.5,
|
|
25
26
|
maxCommandOutputLines: 3,
|
|
26
27
|
readOutputMaxLines: 10,
|
package/dist/shell/shell.d.ts
CHANGED
|
@@ -34,11 +34,8 @@ export declare class Shell implements InputContext {
|
|
|
34
34
|
isAgentActive(): boolean;
|
|
35
35
|
writeToPty(data: string): void;
|
|
36
36
|
/**
|
|
37
|
-
*
|
|
38
|
-
* ZLE widget
|
|
39
|
-
* prompt correctly — we don't try to replay captured bytes.
|
|
40
|
-
*
|
|
41
|
-
* For bash, falls back to sending \n for a fresh prompt cycle.
|
|
37
|
+
* Ask the shell to redraw its own prompt in place via \e[9999~, which both
|
|
38
|
+
* zsh (ZLE widget) and bash (readline redraw-current-line) bind to repaint.
|
|
42
39
|
*/
|
|
43
40
|
redrawPrompt(): void;
|
|
44
41
|
/**
|
package/dist/shell/shell.js
CHANGED
|
@@ -87,7 +87,15 @@ export class Shell {
|
|
|
87
87
|
`[ -f "${userHome}/.bashrc" ] && source "${userHome}/.bashrc"`,
|
|
88
88
|
"",
|
|
89
89
|
"# agent-sh hooks (invisible OSC sequences for cwd + prompt detection)",
|
|
90
|
-
|
|
90
|
+
"# Wrapped in a function because inlining printf \"...\" into",
|
|
91
|
+
"# PROMPT_COMMAND=\"...\" breaks the outer quoting.",
|
|
92
|
+
"__agent_sh_precmd() {",
|
|
93
|
+
` ${osc7Cmd}`,
|
|
94
|
+
` ${promptMarker}`,
|
|
95
|
+
...(showIndicator ? [` ${titleCmd}`] : []),
|
|
96
|
+
" __agent_sh_preexec_ran=0",
|
|
97
|
+
"}",
|
|
98
|
+
`PROMPT_COMMAND="\${PROMPT_COMMAND:+\$PROMPT_COMMAND;}__agent_sh_precmd"`,
|
|
91
99
|
"",
|
|
92
100
|
"# Preexec hook via DEBUG trap: emit actual command text so agent-sh",
|
|
93
101
|
"# can track history-recalled and tab-completed commands accurately",
|
|
@@ -104,6 +112,12 @@ export class Shell {
|
|
|
104
112
|
"",
|
|
105
113
|
"# End-of-prompt marker: append to PS1 (\\[...\\] marks it zero-width)",
|
|
106
114
|
'case "$PS1" in *9998*) ;; *) PS1="${PS1}\\[\\e]9998;READY\\a\\]";; esac',
|
|
115
|
+
"",
|
|
116
|
+
"# Mirrors the zsh \\e[9999~ reset-prompt widget — used by agent-sh",
|
|
117
|
+
"# to repaint the prompt in place. All keymaps so `set -o vi` works.",
|
|
118
|
+
`bind -m emacs '"\\e[9999~":redraw-current-line' 2>/dev/null`,
|
|
119
|
+
`bind -m vi-insert '"\\e[9999~":redraw-current-line' 2>/dev/null`,
|
|
120
|
+
`bind -m vi-command '"\\e[9999~":redraw-current-line' 2>/dev/null`,
|
|
107
121
|
];
|
|
108
122
|
fs.writeFileSync(path.join(this.tmpDir, ".bashrc"), bashrcLines.join("\n") + "\n");
|
|
109
123
|
shellArgs = ["--rcfile", path.join(this.tmpDir, ".bashrc")];
|
|
@@ -190,16 +204,12 @@ export class Shell {
|
|
|
190
204
|
this.ptyProcess.write(data);
|
|
191
205
|
}
|
|
192
206
|
/**
|
|
193
|
-
*
|
|
194
|
-
* ZLE widget
|
|
195
|
-
* prompt correctly — we don't try to replay captured bytes.
|
|
196
|
-
*
|
|
197
|
-
* For bash, falls back to sending \n for a fresh prompt cycle.
|
|
207
|
+
* Ask the shell to redraw its own prompt in place via \e[9999~, which both
|
|
208
|
+
* zsh (ZLE widget) and bash (readline redraw-current-line) bind to repaint.
|
|
198
209
|
*/
|
|
199
210
|
redrawPrompt() {
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
// terminal appear frozen. Reset both before emitting.
|
|
211
|
+
// Stale echoSkip/paused from handleProcessingDone re-entering a mode
|
|
212
|
+
// would swallow the redraw and freeze the terminal visually.
|
|
203
213
|
this.echoSkip = false;
|
|
204
214
|
this.paused = false;
|
|
205
215
|
const result = this.bus.emitPipe("shell:redraw-prompt", {
|
|
@@ -207,14 +217,7 @@ export class Shell {
|
|
|
207
217
|
handled: false,
|
|
208
218
|
});
|
|
209
219
|
if (!result.handled) {
|
|
210
|
-
|
|
211
|
-
// Trigger the hidden ZLE widget — zle reset-prompt redraws cleanly
|
|
212
|
-
this.ptyProcess.write("\x1b[9999~");
|
|
213
|
-
}
|
|
214
|
-
else {
|
|
215
|
-
// Bash: no zle reset-prompt equivalent, use fresh prompt cycle
|
|
216
|
-
this.ptyProcess.write("\n");
|
|
217
|
-
}
|
|
220
|
+
this.ptyProcess.write("\x1b[9999~");
|
|
218
221
|
}
|
|
219
222
|
}
|
|
220
223
|
/**
|
package/dist/types.d.ts
CHANGED
|
@@ -49,6 +49,31 @@ export interface AgentMode {
|
|
|
49
49
|
/** Provider supports the reasoning_effort parameter. */
|
|
50
50
|
supportsReasoningEffort?: boolean;
|
|
51
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* Backend-agnostic LLM interface exposed via `ctx.llm`. Backends fulfill it
|
|
54
|
+
* by defining an `llm:invoke` handler; those without an LLM leave
|
|
55
|
+
* `available` false and calls reject.
|
|
56
|
+
*/
|
|
57
|
+
export interface LlmMessage {
|
|
58
|
+
role: "system" | "user" | "assistant";
|
|
59
|
+
content: string;
|
|
60
|
+
}
|
|
61
|
+
export interface LlmSession {
|
|
62
|
+
send(message: string): Promise<string>;
|
|
63
|
+
history(): ReadonlyArray<LlmMessage>;
|
|
64
|
+
}
|
|
65
|
+
export interface LlmInterface {
|
|
66
|
+
readonly available: boolean;
|
|
67
|
+
ask(opts: {
|
|
68
|
+
query: string;
|
|
69
|
+
system?: string;
|
|
70
|
+
maxTokens?: number;
|
|
71
|
+
}): Promise<string>;
|
|
72
|
+
session(opts?: {
|
|
73
|
+
system?: string;
|
|
74
|
+
maxTokens?: number;
|
|
75
|
+
}): LlmSession;
|
|
76
|
+
}
|
|
52
77
|
export interface AgentShellConfig {
|
|
53
78
|
shell?: string;
|
|
54
79
|
model?: string;
|
|
@@ -102,6 +127,7 @@ export interface ExtensionContext {
|
|
|
102
127
|
registerSkill: (name: string, description: string, filePath: string) => void;
|
|
103
128
|
/** Remove a registered skill by name. */
|
|
104
129
|
removeSkill: (name: string) => void;
|
|
130
|
+
llm: LlmInterface;
|
|
105
131
|
/** Register a named handler. */
|
|
106
132
|
define: (name: string, fn: (...args: any[]) => any) => void;
|
|
107
133
|
/** Wrap a named handler. Receives `next` (original) + args. Returns an unadvise function. */
|
package/dist/utils/ansi.d.ts
CHANGED
|
@@ -7,20 +7,29 @@ export declare const GRAY = "\u001B[90m";
|
|
|
7
7
|
export declare const BOLD = "\u001B[1m";
|
|
8
8
|
export declare const RESET = "\u001B[0m";
|
|
9
9
|
/**
|
|
10
|
-
*
|
|
11
|
-
* Returns 2 for wide chars, 1 for normal chars, 0 for combining chars.
|
|
10
|
+
* Width of a single Unicode code point in terminal columns.
|
|
12
11
|
*
|
|
13
|
-
*
|
|
12
|
+
* For correct rendering of emoji clusters (ZWJ, flags, skin-tone, VS16)
|
|
13
|
+
* prefer `clusterWidth` or `visibleLen`, which segment graphemes first.
|
|
14
|
+
* This code-point-level primitive is kept for callers that iterate over
|
|
15
|
+
* chars for wrap-detection purposes (e.g. CJK line-break rules).
|
|
14
16
|
*/
|
|
15
17
|
export declare function charWidth(codePoint: number): number;
|
|
18
|
+
/**
|
|
19
|
+
* Width of one grapheme cluster in terminal columns. Handles ZWJ sequences,
|
|
20
|
+
* regional-indicator flags, skin-tone modifiers, and VS16 emoji presentation.
|
|
21
|
+
*/
|
|
22
|
+
export declare function clusterWidth(cluster: string): number;
|
|
16
23
|
/**
|
|
17
24
|
* Measure visible string length in terminal columns.
|
|
18
|
-
* Excludes SGR (color/style) sequences and
|
|
25
|
+
* Excludes SGR (color/style) sequences, and counts each grapheme cluster
|
|
26
|
+
* (emoji, CJK, combining marks) as one terminal-visible unit.
|
|
19
27
|
*/
|
|
20
28
|
export declare function visibleLen(str: string): number;
|
|
21
29
|
/**
|
|
22
30
|
* Truncate a string to fit within `maxWidth` visible columns.
|
|
23
|
-
*
|
|
31
|
+
* Iterates by grapheme cluster so emoji sequences (ZWJ, flags, VS16) are
|
|
32
|
+
* kept intact rather than split mid-cluster. Appends `…` if truncated.
|
|
24
33
|
*/
|
|
25
34
|
export declare function truncateToWidth(str: string, maxWidth: number): string;
|
|
26
35
|
/** Truncate to visible width while preserving SGR sequences — use when
|
|
@@ -28,8 +37,10 @@ export declare function truncateToWidth(str: string, maxWidth: number): string;
|
|
|
28
37
|
export declare function truncateAnsiToWidth(str: string, maxWidth: number): string;
|
|
29
38
|
/**
|
|
30
39
|
* Pad a string with spaces to fill `targetWidth` visible columns.
|
|
31
|
-
* Accounts for CJK double-width characters.
|
|
32
40
|
*/
|
|
33
41
|
export declare function padEndToWidth(str: string, targetWidth: number): string;
|
|
34
|
-
/** Strip
|
|
42
|
+
/** Strip ANSI escape sequences and carriage returns.
|
|
43
|
+
* Delegates escape handling to the `strip-ansi` package (covers SGR, OSC,
|
|
44
|
+
* CSI, private-mode, 8-bit CSI, and newer variants). `\r` is not an escape
|
|
45
|
+
* but callers rely on it being stripped alongside. */
|
|
35
46
|
export declare function stripAnsi(str: string): string;
|
package/dist/utils/ansi.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import stringWidth from "string-width";
|
|
2
|
+
import stripAnsiPkg from "strip-ansi";
|
|
1
3
|
// ── ANSI escape code constants ────────────────────────────────
|
|
2
4
|
export const CYAN = "\x1b[36m";
|
|
3
5
|
export const DIM = "\x1b[2m";
|
|
@@ -8,160 +10,65 @@ export const GRAY = "\x1b[90m";
|
|
|
8
10
|
export const BOLD = "\x1b[1m";
|
|
9
11
|
export const RESET = "\x1b[0m";
|
|
10
12
|
// ── ANSI utility functions ───────────────────────────────────
|
|
13
|
+
// Reused across iterations. Segmenter construction is not free, and the API
|
|
14
|
+
// is pure (no per-call state) so a module-level instance is safe.
|
|
15
|
+
const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
11
16
|
/**
|
|
12
|
-
*
|
|
13
|
-
* Returns 2 for wide chars, 1 for normal chars, 0 for combining chars.
|
|
17
|
+
* Width of a single Unicode code point in terminal columns.
|
|
14
18
|
*
|
|
15
|
-
*
|
|
19
|
+
* For correct rendering of emoji clusters (ZWJ, flags, skin-tone, VS16)
|
|
20
|
+
* prefer `clusterWidth` or `visibleLen`, which segment graphemes first.
|
|
21
|
+
* This code-point-level primitive is kept for callers that iterate over
|
|
22
|
+
* chars for wrap-detection purposes (e.g. CJK line-break rules).
|
|
16
23
|
*/
|
|
17
24
|
export function charWidth(codePoint) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
return 0; // Variation Selectors
|
|
31
|
-
if (codePoint >= 0xe0100 && codePoint <= 0xe01ef)
|
|
32
|
-
return 0; // Variation Selectors Supplement
|
|
33
|
-
// Emoji and symbols that render as wide (2 columns)
|
|
34
|
-
// Emoji presentation sequences and keycap
|
|
35
|
-
if (codePoint === 0x20e3)
|
|
36
|
-
return 2; // Combining Enclosing Keycap
|
|
37
|
-
// Emoji blocks
|
|
38
|
-
if (codePoint >= 0x1f600 && codePoint <= 0x1f64f)
|
|
39
|
-
return 2; // Emoticons
|
|
40
|
-
if (codePoint >= 0x1f300 && codePoint <= 0x1f5ff)
|
|
41
|
-
return 2; // Misc Symbols and Pictographs
|
|
42
|
-
if (codePoint >= 0x1f680 && codePoint <= 0x1f6ff)
|
|
43
|
-
return 2; // Transport and Map
|
|
44
|
-
if (codePoint >= 0x1f700 && codePoint <= 0x1f77f)
|
|
45
|
-
return 2; // Alchemical Symbols
|
|
46
|
-
if (codePoint >= 0x1f780 && codePoint <= 0x1f7ff)
|
|
47
|
-
return 2; // Geometric Shapes Extended
|
|
48
|
-
if (codePoint >= 0x1f800 && codePoint <= 0x1f8ff)
|
|
49
|
-
return 2; // Supplemental Arrows-C
|
|
50
|
-
if (codePoint >= 0x1f900 && codePoint <= 0x1f9ff)
|
|
51
|
-
return 2; // Supplemental Symbols and Pictographs
|
|
52
|
-
if (codePoint >= 0x1fa00 && codePoint <= 0x1faff)
|
|
53
|
-
return 2; // Chess Symbols, Symbols and Pictographs Extended-A
|
|
54
|
-
// NOTE: 0x2300-0x23ff (Misc Technical), 0x2600-0x26ff (Misc Symbols),
|
|
55
|
-
// and 0x2700-0x27bf (Dingbats) are mostly "Ambiguous" width — render as
|
|
56
|
-
// 1 column in non-CJK terminal locales (e.g. ❯, ⌘, ★, ♦). But a handful
|
|
57
|
-
// of dingbats have Emoji_Presentation=Yes and render as 2 cols everywhere.
|
|
58
|
-
if (codePoint === 0x2705 || // ✅ white heavy check mark
|
|
59
|
-
codePoint === 0x270a || // ✊ raised fist
|
|
60
|
-
codePoint === 0x270b || // ✋ raised hand
|
|
61
|
-
codePoint === 0x2728 || // ✨ sparkles
|
|
62
|
-
codePoint === 0x274c || // ❌ cross mark
|
|
63
|
-
codePoint === 0x274e || // ❎ negative squared cross mark
|
|
64
|
-
(codePoint >= 0x2753 && codePoint <= 0x2755) || // ❓❔❕
|
|
65
|
-
codePoint === 0x2757 || // ❗ heavy exclamation mark
|
|
66
|
-
(codePoint >= 0x2795 && codePoint <= 0x2797) || // ➕➖➗
|
|
67
|
-
codePoint === 0x27b0 || // ➰ curly loop
|
|
68
|
-
codePoint === 0x27bf // ➿ double curly loop
|
|
69
|
-
)
|
|
70
|
-
return 2;
|
|
71
|
-
// Regional indicator symbols (flag emoji components)
|
|
72
|
-
if (codePoint >= 0x1f1e6 && codePoint <= 0x1f1ff)
|
|
73
|
-
return 2;
|
|
74
|
-
// CJK Unified Ideographs
|
|
75
|
-
if (codePoint >= 0x4e00 && codePoint <= 0x9fff)
|
|
76
|
-
return 2;
|
|
77
|
-
// CJK Unified Ideographs Extension A
|
|
78
|
-
if (codePoint >= 0x3400 && codePoint <= 0x4dbf)
|
|
79
|
-
return 2;
|
|
80
|
-
// Hangul Syllables
|
|
81
|
-
if (codePoint >= 0xac00 && codePoint <= 0xd7af)
|
|
82
|
-
return 2;
|
|
83
|
-
// CJK Unified Ideographs Extension B-F and other CJK blocks
|
|
84
|
-
if (codePoint >= 0x20000 && codePoint <= 0x2ebef)
|
|
85
|
-
return 2;
|
|
86
|
-
// Fullwidth ASCII variants
|
|
87
|
-
if (codePoint >= 0xff01 && codePoint <= 0xff5e)
|
|
88
|
-
return 2;
|
|
89
|
-
// Fullwidth bracket forms
|
|
90
|
-
if (codePoint >= 0xff5f && codePoint <= 0xff60)
|
|
91
|
-
return 2;
|
|
92
|
-
// Fullwidth symbol variants
|
|
93
|
-
if (codePoint >= 0xffe0 && codePoint <= 0xffe6)
|
|
94
|
-
return 2;
|
|
95
|
-
// Japanese hiragana and katakana
|
|
96
|
-
if (codePoint >= 0x3040 && codePoint <= 0x309f)
|
|
97
|
-
return 2;
|
|
98
|
-
if (codePoint >= 0x30a0 && codePoint <= 0x30ff)
|
|
99
|
-
return 2;
|
|
100
|
-
// CJK symbols and punctuation
|
|
101
|
-
if (codePoint >= 0x3000 && codePoint <= 0x303f)
|
|
102
|
-
return 2;
|
|
103
|
-
// Enclosed CJK letters and months
|
|
104
|
-
if (codePoint >= 0x3200 && codePoint <= 0x32ff)
|
|
105
|
-
return 2;
|
|
106
|
-
// CJK compatibility
|
|
107
|
-
if (codePoint >= 0x3300 && codePoint <= 0x33ff)
|
|
108
|
-
return 2;
|
|
109
|
-
// Hangul Jamo
|
|
110
|
-
if (codePoint >= 0x1100 && codePoint <= 0x11ff)
|
|
111
|
-
return 2;
|
|
112
|
-
// Hangul compatibility Jamo
|
|
113
|
-
if (codePoint >= 0x3130 && codePoint <= 0x318f)
|
|
114
|
-
return 2;
|
|
115
|
-
return 1;
|
|
25
|
+
return stringWidth(String.fromCodePoint(codePoint));
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Width of one grapheme cluster in terminal columns. Handles ZWJ sequences,
|
|
29
|
+
* regional-indicator flags, skin-tone modifiers, and VS16 emoji presentation.
|
|
30
|
+
*/
|
|
31
|
+
export function clusterWidth(cluster) {
|
|
32
|
+
return stringWidth(cluster);
|
|
33
|
+
}
|
|
34
|
+
/** Strip SGR (color/style) sequences from a string. */
|
|
35
|
+
function stripSGR(str) {
|
|
36
|
+
return str.replace(/\x1b\[[^m]*m/g, "");
|
|
116
37
|
}
|
|
117
38
|
/**
|
|
118
39
|
* Measure visible string length in terminal columns.
|
|
119
|
-
* Excludes SGR (color/style) sequences and
|
|
40
|
+
* Excludes SGR (color/style) sequences, and counts each grapheme cluster
|
|
41
|
+
* (emoji, CJK, combining marks) as one terminal-visible unit.
|
|
120
42
|
*/
|
|
121
43
|
export function visibleLen(str) {
|
|
122
|
-
|
|
123
|
-
const cleanStr = str.replace(/\x1b\[[^m]*m/g, "");
|
|
124
|
-
let width = 0;
|
|
125
|
-
for (const char of cleanStr) {
|
|
126
|
-
width += charWidth(char.codePointAt(0) ?? 0);
|
|
127
|
-
}
|
|
128
|
-
return width;
|
|
44
|
+
return stringWidth(stripSGR(str));
|
|
129
45
|
}
|
|
130
46
|
/**
|
|
131
47
|
* Truncate a string to fit within `maxWidth` visible columns.
|
|
132
|
-
*
|
|
48
|
+
* Iterates by grapheme cluster so emoji sequences (ZWJ, flags, VS16) are
|
|
49
|
+
* kept intact rather than split mid-cluster. Appends `…` if truncated.
|
|
133
50
|
*/
|
|
134
51
|
export function truncateToWidth(str, maxWidth) {
|
|
135
|
-
const clean = str
|
|
52
|
+
const clean = stripSGR(str);
|
|
136
53
|
if (maxWidth <= 0)
|
|
137
54
|
return "";
|
|
138
|
-
|
|
139
|
-
let fullWidth = 0;
|
|
140
|
-
for (const char of clean) {
|
|
141
|
-
fullWidth += charWidth(char.codePointAt(0) ?? 0);
|
|
142
|
-
}
|
|
143
|
-
if (fullWidth <= maxWidth)
|
|
55
|
+
if (visibleLen(clean) <= maxWidth)
|
|
144
56
|
return clean;
|
|
145
|
-
// String doesn't fit — truncate with "…"
|
|
146
|
-
// At maxWidth=1 the ellipsis alone fills the budget.
|
|
147
57
|
if (maxWidth === 1)
|
|
148
58
|
return "…";
|
|
149
|
-
// Reserve 1 column for "…", so target content width is maxWidth - 1
|
|
150
59
|
const target = maxWidth - 1;
|
|
151
60
|
let width = 0;
|
|
152
|
-
let
|
|
153
|
-
for (const
|
|
154
|
-
const cw =
|
|
61
|
+
let out = "";
|
|
62
|
+
for (const { segment } of GRAPHEME_SEGMENTER.segment(clean)) {
|
|
63
|
+
const cw = clusterWidth(segment);
|
|
155
64
|
if (width + cw > target)
|
|
156
65
|
break;
|
|
157
66
|
width += cw;
|
|
158
|
-
|
|
67
|
+
out += segment;
|
|
159
68
|
}
|
|
160
|
-
|
|
161
|
-
// rather than emit a character that would overflow the budget.
|
|
162
|
-
if (i === 0)
|
|
69
|
+
if (out === "")
|
|
163
70
|
return "…";
|
|
164
|
-
return
|
|
71
|
+
return out + "…";
|
|
165
72
|
}
|
|
166
73
|
/** Truncate to visible width while preserving SGR sequences — use when
|
|
167
74
|
* input carries color/bold codes. `truncateToWidth` strips them. */
|
|
@@ -173,43 +80,57 @@ export function truncateAnsiToWidth(str, maxWidth) {
|
|
|
173
80
|
if (maxWidth === 1)
|
|
174
81
|
return "…";
|
|
175
82
|
const target = maxWidth - 1;
|
|
83
|
+
// Walk the string preserving SGR escapes in-place; buffer text between
|
|
84
|
+
// escapes and segment it into graphemes to count width correctly.
|
|
176
85
|
let width = 0;
|
|
177
86
|
let out = "";
|
|
87
|
+
let buf = "";
|
|
178
88
|
let i = 0;
|
|
89
|
+
const flushBuf = () => {
|
|
90
|
+
if (!buf)
|
|
91
|
+
return false;
|
|
92
|
+
for (const { segment } of GRAPHEME_SEGMENTER.segment(buf)) {
|
|
93
|
+
const cw = clusterWidth(segment);
|
|
94
|
+
if (width + cw > target) {
|
|
95
|
+
buf = "";
|
|
96
|
+
return true; // budget exhausted
|
|
97
|
+
}
|
|
98
|
+
width += cw;
|
|
99
|
+
out += segment;
|
|
100
|
+
}
|
|
101
|
+
buf = "";
|
|
102
|
+
return false;
|
|
103
|
+
};
|
|
179
104
|
while (i < str.length) {
|
|
180
105
|
if (str[i] === "\x1b" && str[i + 1] === "[") {
|
|
181
106
|
const end = str.indexOf("m", i);
|
|
182
107
|
if (end !== -1) {
|
|
108
|
+
if (flushBuf())
|
|
109
|
+
break;
|
|
183
110
|
out += str.slice(i, end + 1);
|
|
184
111
|
i = end + 1;
|
|
185
112
|
continue;
|
|
186
113
|
}
|
|
187
114
|
}
|
|
188
115
|
const cp = str.codePointAt(i) ?? 0;
|
|
189
|
-
const cw = charWidth(cp);
|
|
190
|
-
if (width + cw > target)
|
|
191
|
-
break;
|
|
192
116
|
const chLen = cp > 0xffff ? 2 : 1;
|
|
193
|
-
|
|
194
|
-
width += cw;
|
|
117
|
+
buf += str.slice(i, i + chLen);
|
|
195
118
|
i += chLen;
|
|
196
119
|
}
|
|
120
|
+
flushBuf();
|
|
197
121
|
return out + "\x1b[0m…";
|
|
198
122
|
}
|
|
199
123
|
/**
|
|
200
124
|
* Pad a string with spaces to fill `targetWidth` visible columns.
|
|
201
|
-
* Accounts for CJK double-width characters.
|
|
202
125
|
*/
|
|
203
126
|
export function padEndToWidth(str, targetWidth) {
|
|
204
127
|
const gap = targetWidth - visibleLen(str);
|
|
205
128
|
return gap > 0 ? str + " ".repeat(gap) : str;
|
|
206
129
|
}
|
|
207
|
-
/** Strip
|
|
130
|
+
/** Strip ANSI escape sequences and carriage returns.
|
|
131
|
+
* Delegates escape handling to the `strip-ansi` package (covers SGR, OSC,
|
|
132
|
+
* CSI, private-mode, 8-bit CSI, and newer variants). `\r` is not an escape
|
|
133
|
+
* but callers rely on it being stripped alongside. */
|
|
208
134
|
export function stripAnsi(str) {
|
|
209
|
-
return str
|
|
210
|
-
.replace(/\x1b\][^\x07]*\x07/g, "") // OSC sequences
|
|
211
|
-
.replace(/\x1b\[[^m]*m/g, "") // SGR (color) sequences
|
|
212
|
-
.replace(/\x1b\[\?[^a-zA-Z]*[a-zA-Z]/g, "") // private mode sequences
|
|
213
|
-
.replace(/\x1b\[[^a-zA-Z]*[a-zA-Z]/g, "") // CSI sequences
|
|
214
|
-
.replace(/\r/g, ""); // carriage returns
|
|
135
|
+
return stripAnsiPkg(str).replace(/\r/g, "");
|
|
215
136
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ctx.llm facade — delegates to an `llm:invoke` handler registered by the
|
|
3
|
+
* active backend. No handler → `available` is false and calls reject.
|
|
4
|
+
*/
|
|
5
|
+
import type { HandlerRegistry } from "./handler-registry.js";
|
|
6
|
+
import type { LlmInterface } from "../types.js";
|
|
7
|
+
export declare function createLlmFacade(handlers: HandlerRegistry): LlmInterface;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export function createLlmFacade(handlers) {
|
|
2
|
+
const invoke = (messages, maxTokens) => {
|
|
3
|
+
const result = handlers.call("llm:invoke", messages, { maxTokens });
|
|
4
|
+
if (result === undefined)
|
|
5
|
+
return Promise.reject(new Error("ctx.llm: no LLM backend available"));
|
|
6
|
+
return result;
|
|
7
|
+
};
|
|
8
|
+
return {
|
|
9
|
+
get available() { return handlers.list().includes("llm:invoke"); },
|
|
10
|
+
ask: ({ query, system, maxTokens }) => {
|
|
11
|
+
const messages = [];
|
|
12
|
+
if (system)
|
|
13
|
+
messages.push({ role: "system", content: system });
|
|
14
|
+
messages.push({ role: "user", content: query });
|
|
15
|
+
return invoke(messages, maxTokens);
|
|
16
|
+
},
|
|
17
|
+
session: (opts = {}) => {
|
|
18
|
+
const messages = [];
|
|
19
|
+
if (opts.system)
|
|
20
|
+
messages.push({ role: "system", content: opts.system });
|
|
21
|
+
const session = {
|
|
22
|
+
async send(message) {
|
|
23
|
+
messages.push({ role: "user", content: message });
|
|
24
|
+
const reply = await invoke(messages, opts.maxTokens);
|
|
25
|
+
messages.push({ role: "assistant", content: reply });
|
|
26
|
+
return reply;
|
|
27
|
+
},
|
|
28
|
+
history: () => messages.slice(),
|
|
29
|
+
};
|
|
30
|
+
return session;
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
package/dist/utils/markdown.js
CHANGED
|
@@ -269,12 +269,20 @@ export class MarkdownRenderer {
|
|
|
269
269
|
const separatorWidth = (numCols - 1) * 3;
|
|
270
270
|
const tableWidth = Math.max(10, this.width - 2);
|
|
271
271
|
const availableWidth = tableWidth - separatorWidth;
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
272
|
+
// Shrink the widest column one step at a time until the table fits.
|
|
273
|
+
// Preserves natural width on narrow columns — proportional scaling
|
|
274
|
+
// over-truncates when only one column is oversized.
|
|
275
|
+
let total = colWidths.reduce((a, b) => a + b, 0);
|
|
276
|
+
while (total > availableWidth && availableWidth > numCols) {
|
|
277
|
+
let maxIdx = 0;
|
|
278
|
+
for (let c = 1; c < numCols; c++) {
|
|
279
|
+
if (colWidths[c] > colWidths[maxIdx])
|
|
280
|
+
maxIdx = c;
|
|
277
281
|
}
|
|
282
|
+
if (colWidths[maxIdx] <= 1)
|
|
283
|
+
break;
|
|
284
|
+
colWidths[maxIdx]--;
|
|
285
|
+
total--;
|
|
278
286
|
}
|
|
279
287
|
// Render rows
|
|
280
288
|
const hasHeader = sepIdx.includes(1) && dataRows.length > 1;
|
|
@@ -287,9 +295,13 @@ export class MarkdownRenderer {
|
|
|
287
295
|
const cells = row.map((cell, c) => {
|
|
288
296
|
const w = colWidths[c];
|
|
289
297
|
const rendered = this.renderInline(cell);
|
|
290
|
-
|
|
298
|
+
// Truncation can yield width < w when a CJK double-width char
|
|
299
|
+
// won't fit the remaining budget — always re-pad to keep cells
|
|
300
|
+
// aligned with the border grid.
|
|
301
|
+
const clipped = visibleLen(rendered) > w
|
|
291
302
|
? truncateAnsiToWidth(rendered, w)
|
|
292
|
-
:
|
|
303
|
+
: rendered;
|
|
304
|
+
const text = padEndToWidth(clipped, w);
|
|
293
305
|
return isHeader ? `${p.bold}${text}${p.reset}` : text;
|
|
294
306
|
});
|
|
295
307
|
this.writeLine(`${p.dim}│${p.reset} ${cells.join(` ${p.dim}│${p.reset} `)} ${p.dim}│${p.reset}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sh",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "A shell-first terminal where AI is one keystroke away",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/core.js",
|
|
@@ -34,6 +34,10 @@
|
|
|
34
34
|
"types": "./dist/extensions/index.d.ts",
|
|
35
35
|
"default": "./dist/extensions/index.js"
|
|
36
36
|
},
|
|
37
|
+
"./shell": {
|
|
38
|
+
"types": "./dist/shell/shell.d.ts",
|
|
39
|
+
"default": "./dist/shell/shell.js"
|
|
40
|
+
},
|
|
37
41
|
"./utils/stream-transform": {
|
|
38
42
|
"types": "./dist/utils/stream-transform.d.ts",
|
|
39
43
|
"default": "./dist/utils/stream-transform.js"
|
|
@@ -122,6 +126,8 @@
|
|
|
122
126
|
"marked": "^17.0.6",
|
|
123
127
|
"node-pty": "^1.2.0-beta.12",
|
|
124
128
|
"openai": "^6.34.0",
|
|
129
|
+
"string-width": "^8.2.0",
|
|
130
|
+
"strip-ansi": "^7.2.0",
|
|
125
131
|
"tsx": "^4.19.0"
|
|
126
132
|
},
|
|
127
133
|
"devDependencies": {
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Command suggestion extension (fast-path LLM feature).
|
|
3
|
-
*
|
|
4
|
-
* After a shell command fails (non-zero exit), uses LlmClient.complete()
|
|
5
|
-
* to suggest a fix. Shows the suggestion below the prompt.
|
|
6
|
-
*
|
|
7
|
-
* Only active when an LLM client is available (registered by agent-backend).
|
|
8
|
-
*/
|
|
9
|
-
import type { ExtensionContext } from "../types.js";
|
|
10
|
-
export default function activate({ bus, call }: ExtensionContext): void;
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
export default function activate({ bus, call }) {
|
|
2
|
-
let suggesting = false;
|
|
3
|
-
bus.on("shell:command-done", ({ command, output, exitCode, cwd }) => {
|
|
4
|
-
if (exitCode === null || exitCode === 0)
|
|
5
|
-
return;
|
|
6
|
-
if (!command.trim())
|
|
7
|
-
return;
|
|
8
|
-
if (suggesting)
|
|
9
|
-
return; // don't stack suggestions
|
|
10
|
-
const llmClient = call("llm:get-client");
|
|
11
|
-
if (!llmClient)
|
|
12
|
-
return;
|
|
13
|
-
suggesting = true;
|
|
14
|
-
// Truncate output to avoid blowing up the prompt
|
|
15
|
-
const truncated = output.length > 1000
|
|
16
|
-
? output.slice(-1000)
|
|
17
|
-
: output;
|
|
18
|
-
llmClient.complete({
|
|
19
|
-
messages: [
|
|
20
|
-
{
|
|
21
|
-
role: "system",
|
|
22
|
-
content: "You are a shell assistant. The user's command failed. " +
|
|
23
|
-
"Suggest a fix as a single command. Just the command, no explanation, no backticks, no prefix. " +
|
|
24
|
-
"If you can't suggest anything useful, reply with an empty string.",
|
|
25
|
-
},
|
|
26
|
-
{
|
|
27
|
-
role: "user",
|
|
28
|
-
content: `cwd: ${cwd}\n$ ${command}\n${truncated}\nexit code: ${exitCode}`,
|
|
29
|
-
},
|
|
30
|
-
],
|
|
31
|
-
max_tokens: 150,
|
|
32
|
-
}).then((suggestion) => {
|
|
33
|
-
suggesting = false;
|
|
34
|
-
const trimmed = suggestion.trim().replace(/^`+|`+$/g, ""); // strip backticks
|
|
35
|
-
if (trimmed && trimmed.length < 500) {
|
|
36
|
-
bus.emit("ui:suggestion", { text: trimmed });
|
|
37
|
-
}
|
|
38
|
-
}).catch(() => {
|
|
39
|
-
suggesting = false;
|
|
40
|
-
});
|
|
41
|
-
});
|
|
42
|
-
}
|