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.
@@ -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
- defaultModel: string;
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
- const providerName = config.provider ?? settings.defaultProvider;
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
- if (!effectiveApiKey) {
94
- bus.emit("ui:error", { message: "No LLM provider configured. Set --api-key, configure a provider in ~/.agent-sh/settings.json, or load a provider extension (e.g. openrouter) that sets OPENROUTER_API_KEY." });
95
- return;
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
- initialModeIndex = Math.max(0, modes.findIndex((m) => m.model === effectiveModel && (!activeProvider || m.provider === activeProvider.id)));
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
- // start() emits agent:info after wiring.
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 that were loaded.
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 {};
@@ -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 that were loaded.
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, { title: e.title });
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
- // Route by callId — tools that lost the inline slot get a labeled line.
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
- showToolComplete(e.exitCode, e.resultDisplay, pending?.title);
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 = { callId: extra.toolCallId, title };
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
- const hint = thinking
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, { title: s.openTool.title });
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(process.argv.slice(2));
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 = `${p.muted}Type ${p.warning}>${p.muted} to ask AI · ${p.warning}>/help${p.muted} for commands${p.reset}`;
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
@@ -0,0 +1,3 @@
1
+ export declare function runInit(opts: {
2
+ force: boolean;
3
+ }): void;
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;