agent-sh 0.11.0 → 0.12.1

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.
@@ -67,24 +67,6 @@ export default function agentBackend(ctx) {
67
67
  compositor: ctx.compositor,
68
68
  instanceId: ctx.instanceId,
69
69
  });
70
- bus.emit("agent:register-backend", {
71
- name: "ash",
72
- kill: () => agentLoop.kill(),
73
- start: async () => {
74
- if (!resolved) {
75
- bus.emit("ui:error", { message: "Agent backend not started — no LLM provider available. See earlier messages." });
76
- return;
77
- }
78
- agentLoop.wire();
79
- bus.emit("agent:info", {
80
- name: "ash",
81
- version: PACKAGE_VERSION,
82
- model: llmClient.model,
83
- provider: modes[initialModeIndex]?.provider,
84
- contextWindow: modes[initialModeIndex]?.contextWindow,
85
- });
86
- },
87
- });
88
70
  bus.on("core:extensions-loaded", () => {
89
71
  const settings = getSettings();
90
72
  // If the user didn't pick a default, fall back to the first registered
@@ -99,22 +81,49 @@ export default function agentBackend(ctx) {
99
81
  const effectiveApiKey = config.apiKey ?? activeProvider?.apiKey;
100
82
  const effectiveBaseURL = config.baseURL ?? activeProvider?.baseURL;
101
83
  const effectiveModel = config.model ?? persistedModelFor(providerName) ?? activeProvider?.defaultModel;
102
- if (!effectiveApiKey) {
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." });
104
- return;
105
- }
106
- if (!effectiveModel) {
107
- 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)
108
88
  return;
109
- }
110
89
  modes = buildModes();
111
90
  if (modes.length === 0)
112
91
  modes = [{ model: effectiveModel }];
113
- 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);
114
110
  llmClient.reconfigure({ apiKey: effectiveApiKey, baseURL: effectiveBaseURL, model: effectiveModel });
115
111
  bus.emit("config:set-modes", { modes, activeIndex: initialModeIndex });
116
112
  resolved = true;
117
- // 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
+ });
118
127
  });
119
128
  bus.on("provider:register", (p) => {
120
129
  const rawModels = p.models ?? (p.defaultModel ? [p.defaultModel] : []);
@@ -1,10 +1,5 @@
1
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
- ];
2
+ const DEFAULT_MODELS = ["anthropic/claude-sonnet-4.6"];
8
3
  export default function activate(ctx) {
9
4
  const apiKey = process.env.OPENROUTER_API_KEY;
10
5
  if (!apiKey)
@@ -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
@@ -270,6 +270,16 @@ async function main() {
270
270
  // ── Activate agent backend ────────────────────────────────────
271
271
  // Extensions had their chance to register via agent:register-backend.
272
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
+ }
273
283
  core.activateBackend();
274
284
  // ── Startup banner ───────────────────────────────────────────
275
285
  const settings = getSettings();
@@ -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;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Dumps every LLM request + streamed chunk to $AGENT_SH_WIRE_DIR
3
+ * (default ~/.agent-sh/wire) for offline replay via curl. Paired files
4
+ * per turn: <stamp>.request.json and <stamp>.chunks.jsonl.
5
+ */
6
+ import * as fs from "node:fs";
7
+ import * as path from "node:path";
8
+ import * as os from "node:os";
9
+ import type { ExtensionContext } from "agent-sh/types";
10
+
11
+ export default function activate(ctx: ExtensionContext): void {
12
+ const dir = process.env.AGENT_SH_WIRE_DIR
13
+ ?? path.join(os.homedir(), ".agent-sh", "wire");
14
+ fs.mkdirSync(dir, { recursive: true });
15
+
16
+ // llm:chunk has no back-pointer to its request, so anchor both on
17
+ // the timestamp set when llm:request fires.
18
+ let currentStamp: string | null = null;
19
+
20
+ ctx.bus.on("llm:request", (req) => {
21
+ currentStamp = new Date().toISOString().replace(/[:.]/g, "-");
22
+ fs.writeFileSync(
23
+ path.join(dir, `${currentStamp}.request.json`),
24
+ JSON.stringify(req, null, 2),
25
+ );
26
+ });
27
+
28
+ ctx.bus.on("llm:chunk", ({ chunk }) => {
29
+ if (!currentStamp) return;
30
+ fs.appendFileSync(
31
+ path.join(dir, `${currentStamp}.chunks.jsonl`),
32
+ JSON.stringify(chunk) + "\n",
33
+ );
34
+ });
35
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sh",
3
- "version": "0.11.0",
3
+ "version": "0.12.1",
4
4
  "description": "A shell-first terminal where AI is one keystroke away",
5
5
  "type": "module",
6
6
  "main": "dist/core.js",
@@ -89,13 +89,19 @@
89
89
  },
90
90
  "files": [
91
91
  "dist",
92
- "examples"
92
+ "examples/extensions/*.ts",
93
+ "examples/extensions/*/package.json",
94
+ "examples/extensions/*/tsconfig.json",
95
+ "examples/extensions/*/README.md",
96
+ "examples/extensions/*/src",
97
+ "examples/extensions/*/index.ts",
98
+ "examples/extensions/*/index.js"
93
99
  ],
94
100
  "scripts": {
95
101
  "dev": "tsx src/index.ts",
96
102
  "build": "tsc",
97
103
  "start": "node dist/index.js",
98
- "prepublishOnly": "npm run build"
104
+ "prepare": "npm run build"
99
105
  },
100
106
  "keywords": [
101
107
  "terminal",