agent-sh 0.10.3 → 0.12.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 +42 -8
- package/dist/agent/agent-loop.js +87 -69
- package/dist/agent/conversation-state.d.ts +8 -1
- package/dist/agent/conversation-state.js +35 -14
- package/dist/agent/subagent.d.ts +8 -4
- package/dist/agent/subagent.js +45 -5
- package/dist/agent/tool-protocol.d.ts +5 -5
- package/dist/agent/tool-protocol.js +8 -8
- package/dist/core.d.ts +1 -1
- package/dist/core.js +2 -0
- package/dist/event-bus.d.ts +12 -1
- package/dist/extensions/agent-backend.js +46 -28
- 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 +39 -0
- package/dist/extensions/tui-renderer.js +65 -19
- package/dist/index.js +24 -3
- package/dist/init.d.ts +3 -0
- package/dist/init.js +72 -0
- package/dist/shell/input-handler.js +30 -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/llm-facade.d.ts +7 -0
- package/dist/utils/llm-facade.js +33 -0
- package/examples/extensions/wire-log.ts +35 -0
- package/package.json +2 -2
- package/dist/extensions/command-suggest.d.ts +0 -10
- package/dist/extensions/command-suggest.js +0 -42
package/dist/event-bus.d.ts
CHANGED
|
@@ -65,6 +65,15 @@ export interface ShellEvents {
|
|
|
65
65
|
completion_tokens: number;
|
|
66
66
|
total_tokens: number;
|
|
67
67
|
};
|
|
68
|
+
"llm:request": {
|
|
69
|
+
messages: unknown[];
|
|
70
|
+
tools?: unknown;
|
|
71
|
+
model?: string;
|
|
72
|
+
reasoning_effort?: string;
|
|
73
|
+
};
|
|
74
|
+
"llm:chunk": {
|
|
75
|
+
chunk: unknown;
|
|
76
|
+
};
|
|
68
77
|
"agent:processing-start": Record<string, never>;
|
|
69
78
|
"agent:processing-done": Record<string, never>;
|
|
70
79
|
"agent:cancelled": Record<string, never>;
|
|
@@ -275,7 +284,9 @@ export interface ShellEvents {
|
|
|
275
284
|
id: string;
|
|
276
285
|
apiKey?: string;
|
|
277
286
|
baseURL?: string;
|
|
278
|
-
|
|
287
|
+
/** Optional — providers for custom endpoints may not know the catalog
|
|
288
|
+
* at registration time. Falls back to models[0] when absent. */
|
|
289
|
+
defaultModel?: string;
|
|
279
290
|
models?: (string | {
|
|
280
291
|
id: string;
|
|
281
292
|
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;
|
|
@@ -61,27 +67,12 @@ export default function agentBackend(ctx) {
|
|
|
61
67
|
compositor: ctx.compositor,
|
|
62
68
|
instanceId: ctx.instanceId,
|
|
63
69
|
});
|
|
64
|
-
bus.emit("agent:register-backend", {
|
|
65
|
-
name: "ash",
|
|
66
|
-
kill: () => agentLoop.kill(),
|
|
67
|
-
start: async () => {
|
|
68
|
-
if (!resolved) {
|
|
69
|
-
bus.emit("ui:error", { message: "Agent backend not started — no LLM provider available. See earlier messages." });
|
|
70
|
-
return;
|
|
71
|
-
}
|
|
72
|
-
agentLoop.wire();
|
|
73
|
-
bus.emit("agent:info", {
|
|
74
|
-
name: "ash",
|
|
75
|
-
version: PACKAGE_VERSION,
|
|
76
|
-
model: llmClient.model,
|
|
77
|
-
provider: modes[initialModeIndex]?.provider,
|
|
78
|
-
contextWindow: modes[initialModeIndex]?.contextWindow,
|
|
79
|
-
});
|
|
80
|
-
},
|
|
81
|
-
});
|
|
82
70
|
bus.on("core:extensions-loaded", () => {
|
|
83
71
|
const settings = getSettings();
|
|
84
|
-
|
|
72
|
+
// If the user didn't pick a default, fall back to the first registered
|
|
73
|
+
// provider (built-in load order biases to openrouter → openai).
|
|
74
|
+
const providerName = config.provider ?? settings.defaultProvider
|
|
75
|
+
?? (providerRegistry.size > 0 ? providerRegistry.keys().next().value : undefined);
|
|
85
76
|
const activeProvider = providerName ? providerRegistry.get(providerName) ?? null : null;
|
|
86
77
|
// User's persisted defaultModel wins over the provider's declared
|
|
87
78
|
// default. Dynamic providers (openrouter) re-register with their
|
|
@@ -90,22 +81,49 @@ export default function agentBackend(ctx) {
|
|
|
90
81
|
const effectiveApiKey = config.apiKey ?? activeProvider?.apiKey;
|
|
91
82
|
const effectiveBaseURL = config.baseURL ?? activeProvider?.baseURL;
|
|
92
83
|
const effectiveModel = config.model ?? persistedModelFor(providerName) ?? activeProvider?.defaultModel;
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
if (!effectiveModel) {
|
|
98
|
-
bus.emit("ui:error", { message: "No model specified. Use --model or configure a provider with defaultModel in ~/.agent-sh/settings.json" });
|
|
84
|
+
// No provider → don't register ash at all, so another backend (e.g.
|
|
85
|
+
// claude-code-bridge) can own activation. index.ts hard-fails only
|
|
86
|
+
// when no backend ended up registered.
|
|
87
|
+
if (!effectiveApiKey || !effectiveModel)
|
|
99
88
|
return;
|
|
100
|
-
}
|
|
101
89
|
modes = buildModes();
|
|
102
90
|
if (modes.length === 0)
|
|
103
91
|
modes = [{ model: effectiveModel }];
|
|
104
|
-
|
|
92
|
+
let foundIdx = modes.findIndex((m) => m.model === effectiveModel && (!activeProvider || m.provider === activeProvider.id));
|
|
93
|
+
// Persisted default may not be in the provider's curated list yet (e.g.
|
|
94
|
+
// openrouter's async catalog fetch hasn't returned). Prepend a stub so
|
|
95
|
+
// the initial config:set-modes activeIndex points at the real model —
|
|
96
|
+
// otherwise AgentLoop reconfigures llmClient back to modes[0].
|
|
97
|
+
if (foundIdx === -1 && activeProvider) {
|
|
98
|
+
modes = [
|
|
99
|
+
{
|
|
100
|
+
model: effectiveModel,
|
|
101
|
+
provider: activeProvider.id,
|
|
102
|
+
providerConfig: { apiKey: effectiveApiKey, baseURL: effectiveBaseURL },
|
|
103
|
+
supportsReasoningEffort: activeProvider.supportsReasoningEffort,
|
|
104
|
+
},
|
|
105
|
+
...modes,
|
|
106
|
+
];
|
|
107
|
+
foundIdx = 0;
|
|
108
|
+
}
|
|
109
|
+
initialModeIndex = Math.max(0, foundIdx);
|
|
105
110
|
llmClient.reconfigure({ apiKey: effectiveApiKey, baseURL: effectiveBaseURL, model: effectiveModel });
|
|
106
111
|
bus.emit("config:set-modes", { modes, activeIndex: initialModeIndex });
|
|
107
112
|
resolved = true;
|
|
108
|
-
|
|
113
|
+
bus.emit("agent:register-backend", {
|
|
114
|
+
name: "ash",
|
|
115
|
+
kill: () => agentLoop.kill(),
|
|
116
|
+
start: async () => {
|
|
117
|
+
agentLoop.wire();
|
|
118
|
+
bus.emit("agent:info", {
|
|
119
|
+
name: "ash",
|
|
120
|
+
version: PACKAGE_VERSION,
|
|
121
|
+
model: llmClient.model,
|
|
122
|
+
provider: modes[initialModeIndex]?.provider,
|
|
123
|
+
contextWindow: modes[initialModeIndex]?.contextWindow,
|
|
124
|
+
});
|
|
125
|
+
},
|
|
126
|
+
});
|
|
109
127
|
});
|
|
110
128
|
bus.on("provider:register", (p) => {
|
|
111
129
|
const rawModels = p.models ?? (p.defaultModel ? [p.defaultModel] : []);
|
|
@@ -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,39 @@
|
|
|
1
|
+
const BASE_URL = "https://openrouter.ai/api/v1";
|
|
2
|
+
const DEFAULT_MODELS = ["anthropic/claude-sonnet-4.6"];
|
|
3
|
+
export default function activate(ctx) {
|
|
4
|
+
const apiKey = process.env.OPENROUTER_API_KEY;
|
|
5
|
+
if (!apiKey)
|
|
6
|
+
return;
|
|
7
|
+
ctx.bus.emit("provider:register", {
|
|
8
|
+
id: "openrouter",
|
|
9
|
+
apiKey,
|
|
10
|
+
baseURL: BASE_URL,
|
|
11
|
+
defaultModel: DEFAULT_MODELS[0],
|
|
12
|
+
models: DEFAULT_MODELS,
|
|
13
|
+
});
|
|
14
|
+
fetchModels(apiKey).then((models) => {
|
|
15
|
+
if (models.length === 0)
|
|
16
|
+
return;
|
|
17
|
+
ctx.bus.emit("provider:register", {
|
|
18
|
+
id: "openrouter",
|
|
19
|
+
apiKey,
|
|
20
|
+
baseURL: BASE_URL,
|
|
21
|
+
defaultModel: DEFAULT_MODELS[0],
|
|
22
|
+
supportsReasoningEffort: true,
|
|
23
|
+
models: models.map((m) => ({
|
|
24
|
+
id: m.id,
|
|
25
|
+
reasoning: m.supported_parameters?.includes("reasoning") ?? false,
|
|
26
|
+
contextWindow: m.context_length,
|
|
27
|
+
})),
|
|
28
|
+
});
|
|
29
|
+
}).catch(() => { });
|
|
30
|
+
}
|
|
31
|
+
async function fetchModels(apiKey) {
|
|
32
|
+
const res = await fetch(`${BASE_URL}/models`, {
|
|
33
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
34
|
+
});
|
|
35
|
+
if (!res.ok)
|
|
36
|
+
return [];
|
|
37
|
+
const data = await res.json();
|
|
38
|
+
return data.data ?? [];
|
|
39
|
+
}
|
|
@@ -50,6 +50,7 @@ function createRenderState() {
|
|
|
50
50
|
spinnerStartTime: 0,
|
|
51
51
|
openTool: null,
|
|
52
52
|
pendingToolCompletes: new Map(),
|
|
53
|
+
orphanContHeaderKind: undefined,
|
|
53
54
|
currentToolKind: undefined,
|
|
54
55
|
toolStartTime: 0,
|
|
55
56
|
toolExitCode: null,
|
|
@@ -178,11 +179,8 @@ export default function activate(ctx) {
|
|
|
178
179
|
stopCurrentSpinner();
|
|
179
180
|
if (!s.renderer)
|
|
180
181
|
startAgentResponse();
|
|
181
|
-
s.renderer.writeLine(`${p.dim}Thinking (ctrl+t to collapse)${p.reset}`);
|
|
182
|
-
drain();
|
|
183
182
|
}
|
|
184
183
|
else {
|
|
185
|
-
// Restart spinner with ctrl+t hint now that we know thinking is available
|
|
186
184
|
startThinkingSpinner();
|
|
187
185
|
}
|
|
188
186
|
}
|
|
@@ -256,6 +254,7 @@ export default function activate(ctx) {
|
|
|
256
254
|
return;
|
|
257
255
|
fencedTransform.flush();
|
|
258
256
|
finalizeToolGroup();
|
|
257
|
+
s.orphanContHeaderKind = undefined;
|
|
259
258
|
batchGroups = new Map();
|
|
260
259
|
for (const group of e.groups) {
|
|
261
260
|
batchGroups.set(group.kind, {
|
|
@@ -272,6 +271,7 @@ export default function activate(ctx) {
|
|
|
272
271
|
stopCurrentSpinner();
|
|
273
272
|
s.currentToolKind = e.kind;
|
|
274
273
|
s.toolStartTime = Date.now();
|
|
274
|
+
s.orphanContHeaderKind = undefined;
|
|
275
275
|
if (e.title === "user_shell") {
|
|
276
276
|
finalizeToolGroup();
|
|
277
277
|
closeToolLine();
|
|
@@ -315,10 +315,12 @@ export default function activate(ctx) {
|
|
|
315
315
|
showToolCall(e.title, "", { ...e, groupContinuation: true });
|
|
316
316
|
s.toolGroupRendered++;
|
|
317
317
|
}
|
|
318
|
-
// Record identity so late completes (after a premature finalize
|
|
319
|
-
// from a cross-kind standalone start) can render as labeled ⎿ lines.
|
|
320
318
|
if (e.toolCallId) {
|
|
321
|
-
s.pendingToolCompletes.set(e.toolCallId, {
|
|
319
|
+
s.pendingToolCompletes.set(e.toolCallId, {
|
|
320
|
+
title: e.title,
|
|
321
|
+
kind,
|
|
322
|
+
displayDetail: e.displayDetail ?? extractDetail(e),
|
|
323
|
+
});
|
|
322
324
|
}
|
|
323
325
|
}
|
|
324
326
|
else {
|
|
@@ -342,13 +344,25 @@ export default function activate(ctx) {
|
|
|
342
344
|
s.pendingToolCompletes.delete(e.toolCallId);
|
|
343
345
|
s.toolGroupCompletedCount++;
|
|
344
346
|
s.currentToolKind = undefined;
|
|
347
|
+
// Finalize as soon as all members return so aggregate lands right
|
|
348
|
+
// after its children, not below out-of-band renders from the next tool.
|
|
349
|
+
const batchGroup = batchGroups.get(s.toolGroupKind);
|
|
350
|
+
if (batchGroup && s.toolGroupCompletedCount >= batchGroup.total) {
|
|
351
|
+
finalizeToolGroup();
|
|
352
|
+
}
|
|
345
353
|
}
|
|
346
354
|
else {
|
|
347
|
-
//
|
|
355
|
+
// Tools that lost the inline slot render as a labeled ⎿. Orphans
|
|
356
|
+
// (group finalized before they returned) reroute via showOrphanedComplete.
|
|
348
357
|
const pending = e.toolCallId ? s.pendingToolCompletes.get(e.toolCallId) : undefined;
|
|
349
358
|
if (pending)
|
|
350
359
|
s.pendingToolCompletes.delete(e.toolCallId);
|
|
351
|
-
|
|
360
|
+
if (pending?.orphaned) {
|
|
361
|
+
showOrphanedComplete(e.exitCode, e.resultDisplay, pending.title, pending.kind, pending.displayDetail);
|
|
362
|
+
}
|
|
363
|
+
else {
|
|
364
|
+
showToolComplete(e.exitCode, e.resultDisplay, pending?.displayDetail ?? pending?.title);
|
|
365
|
+
}
|
|
352
366
|
s.currentToolKind = undefined;
|
|
353
367
|
s.spinnerStartTime = 0;
|
|
354
368
|
startThinkingSpinner();
|
|
@@ -746,8 +760,14 @@ export default function activate(ctx) {
|
|
|
746
760
|
}
|
|
747
761
|
else {
|
|
748
762
|
out().write(` ${batchPrefix}${lines[lines.length - 1]}`);
|
|
749
|
-
if (extra?.toolCallId)
|
|
750
|
-
s.openTool = {
|
|
763
|
+
if (extra?.toolCallId) {
|
|
764
|
+
s.openTool = {
|
|
765
|
+
callId: extra.toolCallId,
|
|
766
|
+
title,
|
|
767
|
+
kind: extra.kind,
|
|
768
|
+
displayDetail: extra.displayDetail ?? extractDetail(extra),
|
|
769
|
+
};
|
|
770
|
+
}
|
|
751
771
|
}
|
|
752
772
|
}
|
|
753
773
|
s.hadToolCalls = true;
|
|
@@ -775,6 +795,26 @@ export default function activate(ctx) {
|
|
|
775
795
|
if (resultDisplay?.body)
|
|
776
796
|
renderResultBody(resultDisplay.body);
|
|
777
797
|
}
|
|
798
|
+
/** Late completion from a finalized group — re-emit the kind header
|
|
799
|
+
* in muted "(cont.)" form so the ⎿ has a legitimate parent, then
|
|
800
|
+
* render the completion as a normal labeled ⎿. Subsequent orphans
|
|
801
|
+
* of the same kind reuse the existing (cont.) header. */
|
|
802
|
+
function showOrphanedComplete(exitCode, resultDisplay, title, kind, displayDetail) {
|
|
803
|
+
if (s.orphanContHeaderKind !== kind) {
|
|
804
|
+
stopCurrentSpinner();
|
|
805
|
+
closeToolLine();
|
|
806
|
+
flushCommandOutput();
|
|
807
|
+
if (!s.renderer)
|
|
808
|
+
startAgentResponse();
|
|
809
|
+
showCollapsedThinking();
|
|
810
|
+
const icon = (kind && KIND_ICONS[kind]) ?? "▶";
|
|
811
|
+
const label = kind ?? "tool";
|
|
812
|
+
s.renderer.writeLine(`${p.muted}${icon} ${label} (cont.)${p.reset}`);
|
|
813
|
+
drain();
|
|
814
|
+
s.orphanContHeaderKind = kind;
|
|
815
|
+
}
|
|
816
|
+
showToolComplete(exitCode, resultDisplay, displayDetail || title);
|
|
817
|
+
}
|
|
778
818
|
function renderResultBody(body) {
|
|
779
819
|
if (!s.renderer)
|
|
780
820
|
return;
|
|
@@ -796,10 +836,7 @@ export default function activate(ctx) {
|
|
|
796
836
|
stopCurrentSpinner();
|
|
797
837
|
const thinking = hasThinkingMode();
|
|
798
838
|
s.spinnerLabel = thinking ? "Thinking" : "Working";
|
|
799
|
-
|
|
800
|
-
? (s.showThinkingText ? "(ctrl+t to collapse)" : "(ctrl+t to expand)")
|
|
801
|
-
: "";
|
|
802
|
-
s.spinnerOpts = { hint: hint || undefined, startTime: s.spinnerStartTime };
|
|
839
|
+
s.spinnerOpts = { startTime: s.spinnerStartTime };
|
|
803
840
|
s.spinner = createSpinner({ startTime: s.spinnerStartTime });
|
|
804
841
|
s.spinnerInterval = setInterval(() => {
|
|
805
842
|
if (s.spinner) {
|
|
@@ -825,13 +862,25 @@ export default function activate(ctx) {
|
|
|
825
862
|
if (s.openTool) {
|
|
826
863
|
out().write("\n");
|
|
827
864
|
// Stash identity so the completion renders as ⎿ labeled, not orphan ✓.
|
|
828
|
-
s.pendingToolCompletes.set(s.openTool.callId, {
|
|
865
|
+
s.pendingToolCompletes.set(s.openTool.callId, {
|
|
866
|
+
title: s.openTool.title,
|
|
867
|
+
kind: s.openTool.kind,
|
|
868
|
+
displayDetail: s.openTool.displayDetail,
|
|
869
|
+
});
|
|
829
870
|
s.openTool = null;
|
|
830
871
|
}
|
|
831
872
|
}
|
|
832
873
|
/** Render the group aggregate ⎿ line, or skip if no members have
|
|
833
874
|
* completed yet (late completes will render individually as ⎿ labeled). */
|
|
834
875
|
function finalizeToolGroup() {
|
|
876
|
+
// Late completes from this group have lost their inline slot; mark
|
|
877
|
+
// them so showOrphanedComplete re-emits a (cont.) header for their ⎿.
|
|
878
|
+
if (s.toolGroupKind) {
|
|
879
|
+
for (const pending of s.pendingToolCompletes.values()) {
|
|
880
|
+
if (pending.kind === s.toolGroupKind)
|
|
881
|
+
pending.orphaned = true;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
835
884
|
const skipAggregate = s.toolGroupCount > 1 && s.toolGroupCompletedCount === 0;
|
|
836
885
|
if (s.toolGroupCount <= 1 || skipAggregate) {
|
|
837
886
|
s.toolGroupKind = undefined;
|
|
@@ -842,6 +891,7 @@ export default function activate(ctx) {
|
|
|
842
891
|
s.toolGroupSummaries = [];
|
|
843
892
|
return;
|
|
844
893
|
}
|
|
894
|
+
stopCurrentSpinner();
|
|
845
895
|
closeToolLine();
|
|
846
896
|
if (!s.renderer)
|
|
847
897
|
startAgentResponse();
|
|
@@ -938,14 +988,10 @@ export default function activate(ctx) {
|
|
|
938
988
|
if (s.spinner) {
|
|
939
989
|
stopCurrentSpinner();
|
|
940
990
|
if (s.showThinkingText) {
|
|
941
|
-
// Expanding: replace spinner with thinking text header
|
|
942
991
|
if (!s.renderer)
|
|
943
992
|
startAgentResponse();
|
|
944
|
-
s.renderer.writeLine(`${p.dim}Thinking (ctrl+t to collapse)${p.reset}`);
|
|
945
|
-
drain();
|
|
946
993
|
}
|
|
947
994
|
else {
|
|
948
|
-
// Collapsing: restart spinner with updated hint
|
|
949
995
|
startThinkingSpinner();
|
|
950
996
|
}
|
|
951
997
|
return;
|
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)) {
|
|
@@ -262,6 +270,16 @@ async function main() {
|
|
|
262
270
|
// ── Activate agent backend ────────────────────────────────────
|
|
263
271
|
// Extensions had their chance to register via agent:register-backend.
|
|
264
272
|
// If none did, the built-in AgentLoop gets wired to bus events.
|
|
273
|
+
const { names: backendNames } = core.bus.emitPipe("config:get-backends", { names: [], active: null });
|
|
274
|
+
if (backendNames.length === 0) {
|
|
275
|
+
shell.kill();
|
|
276
|
+
console.error("\nagent-sh: no agent backend available.\n\n" +
|
|
277
|
+
" Export OPENROUTER_API_KEY or OPENAI_API_KEY for zero-config launch, or\n" +
|
|
278
|
+
" pass --api-key on the command line, or\n" +
|
|
279
|
+
" run `agent-sh init` for a settings.json template.\n" +
|
|
280
|
+
" Alternatively, install a bridge extension (claude-code-bridge, pi-bridge).\n");
|
|
281
|
+
process.exit(1);
|
|
282
|
+
}
|
|
265
283
|
core.activateBackend();
|
|
266
284
|
// ── Startup banner ───────────────────────────────────────────
|
|
267
285
|
const settings = getSettings();
|
|
@@ -270,6 +288,7 @@ async function main() {
|
|
|
270
288
|
const bannerW = Math.min(termW, 60);
|
|
271
289
|
const productName = `${p.accent}${p.bold}agent-sh${p.reset}`;
|
|
272
290
|
const info = agentInfo;
|
|
291
|
+
const backendReady = !!info?.model;
|
|
273
292
|
const backendName = info?.name ?? "ash";
|
|
274
293
|
const model = info?.model;
|
|
275
294
|
const provider = info?.provider;
|
|
@@ -277,7 +296,7 @@ async function main() {
|
|
|
277
296
|
? provider ? `${model} [${provider}]` : model
|
|
278
297
|
: null;
|
|
279
298
|
let sections = "";
|
|
280
|
-
sections += `\n\n ${p.muted}Backend:${p.reset} ${p.dim}${backendName}${p.reset}`;
|
|
299
|
+
sections += `\n\n ${p.muted}Backend:${p.reset} ${p.dim}${backendName}${backendReady ? "" : " (not configured)"}${p.reset}`;
|
|
281
300
|
if (modelValue) {
|
|
282
301
|
sections += `\n ${p.muted}Model:${p.reset} ${p.dim}${modelValue}${p.reset}`;
|
|
283
302
|
}
|
|
@@ -300,7 +319,9 @@ async function main() {
|
|
|
300
319
|
sections += `\n ${p.dim}${item}${p.reset}`;
|
|
301
320
|
}
|
|
302
321
|
}
|
|
303
|
-
const hint =
|
|
322
|
+
const hint = backendReady
|
|
323
|
+
? `${p.muted}Type ${p.warning}>${p.muted} to ask AI · ${p.warning}>/help${p.muted} for commands${p.reset}`
|
|
324
|
+
: `${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
325
|
const borderLine = `${p.muted}${"─".repeat(bannerW)}${p.reset}`;
|
|
305
326
|
process.stdout.write("\n" + borderLine + "\n" +
|
|
306
327
|
" " + 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
|
+
}
|
|
@@ -253,6 +253,36 @@ export class InputHandler {
|
|
|
253
253
|
seq += data[i];
|
|
254
254
|
}
|
|
255
255
|
}
|
|
256
|
+
else if (next === "]" || next === "P" || next === "_" || next === "^") {
|
|
257
|
+
// String sequences terminated by BEL or ST (ESC \):
|
|
258
|
+
// OSC (ESC ]) — OSC 10/11 color-query responses
|
|
259
|
+
// DCS (ESC P) — tmux XTVERSION query response (iTerm2 etc.)
|
|
260
|
+
// APC (ESC _), PM (ESC ^) — rarer, same termination
|
|
261
|
+
// Forward as a unit so the payload doesn't leak into lineBuffer
|
|
262
|
+
// and onto the bash command line after a foreground app exits.
|
|
263
|
+
let j = i + 2;
|
|
264
|
+
let termEnd = -1;
|
|
265
|
+
while (j < data.length) {
|
|
266
|
+
const c = data[j];
|
|
267
|
+
if (c === "\x07") {
|
|
268
|
+
termEnd = j;
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
if (c === "\x1b" && j + 1 < data.length && data[j + 1] === "\\") {
|
|
272
|
+
termEnd = j + 1;
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
j++;
|
|
276
|
+
}
|
|
277
|
+
if (termEnd !== -1) {
|
|
278
|
+
seq = data.slice(i, termEnd + 1);
|
|
279
|
+
i = termEnd;
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
seq += next;
|
|
283
|
+
i++;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
256
286
|
else {
|
|
257
287
|
// ESC + single char (alt-key, etc.)
|
|
258
288
|
seq += next;
|