agent-sh 0.15.7 → 0.15.8

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.
@@ -84,6 +84,9 @@ export declare class AgentLoop implements AgentBackend {
84
84
  kill(): void;
85
85
  private cancel;
86
86
  private reasoningParams;
87
+ /** Resume-stable conversation id from the frontend (e.g. ashi); undefined
88
+ * when the frontend tracks no session. */
89
+ private currentSessionId;
87
90
  private resolveEndpoint;
88
91
  private pullModels;
89
92
  private emitIdentity;
@@ -390,6 +390,17 @@ export class AgentLoop {
390
390
  const effort = this.thinkingLevel === "xhigh" ? "high" : this.thinkingLevel;
391
391
  return { reasoning_effort: effort };
392
392
  }
393
+ /** Resume-stable conversation id from the frontend (e.g. ashi); undefined
394
+ * when the frontend tracks no session. */
395
+ currentSessionId() {
396
+ try {
397
+ const id = this.handlers.call("session:current-id");
398
+ return typeof id === "string" && id ? id : undefined;
399
+ }
400
+ catch {
401
+ return undefined;
402
+ }
403
+ }
393
404
  resolveEndpoint(m) {
394
405
  try {
395
406
  return this.handlers.call("agent:resolve-endpoint", { provider: m.provider, id: m.id });
@@ -1251,7 +1262,12 @@ export class AgentLoop {
1251
1262
  ...this.reasoningParams(),
1252
1263
  };
1253
1264
  this.bus.emit("llm:request", requestParams);
1254
- const stream = await this.llmClient.stream({ ...requestParams, signal });
1265
+ const headers = this.activeEndpoint?.buildRequestHeaders?.({ sessionId: this.currentSessionId() });
1266
+ const stream = await this.llmClient.stream({
1267
+ ...requestParams,
1268
+ signal,
1269
+ ...(headers && Object.keys(headers).length ? { headers } : {}),
1270
+ });
1255
1271
  try {
1256
1272
  for await (const chunk of stream) {
1257
1273
  if (signal.aborted)
@@ -21,6 +21,9 @@ declare module "../core/event-bus.js" {
21
21
  id: string;
22
22
  reasoningParams?: (level: string, model?: string) => Record<string, unknown>;
23
23
  cacheTokens?: (usage: Record<string, unknown>) => number | undefined;
24
+ requestHeaders?: (info: {
25
+ sessionId?: string;
26
+ }) => Record<string, string>;
24
27
  };
25
28
  "agent:models-changed": Record<string, never>;
26
29
  "config:switch-provider": {
@@ -85,6 +85,9 @@ export interface ModelEndpoint {
85
85
  baseURL?: string;
86
86
  buildReasoningParams?: (level: string) => Record<string, unknown>;
87
87
  extractCachedTokens?: (usage: Record<string, unknown>) => number | undefined;
88
+ buildRequestHeaders?: (info: {
89
+ sessionId?: string;
90
+ }) => Record<string, string>;
88
91
  }
89
92
  /**
90
93
  * Capabilities the agent host adds on top of CoreContext. Only available
@@ -100,6 +103,9 @@ export interface AgentSurface {
100
103
  configure: (id: string, opts: {
101
104
  reasoningParams?: (level: string, model?: string) => Record<string, unknown>;
102
105
  cacheTokens?: (usage: Record<string, unknown>) => number | undefined;
106
+ requestHeaders?: (info: {
107
+ sessionId?: string;
108
+ }) => Record<string, string>;
103
109
  }) => void;
104
110
  };
105
111
  registerTool: (tool: ToolDefinition) => void;
@@ -105,6 +105,7 @@ export default function agentBackend(ctx) {
105
105
  return hook ? (level) => hook(level, model) : defaultReasoningBuilder;
106
106
  };
107
107
  const bindCacheTokens = (shapeId) => providerHooks.get(shapeId)?.cacheTokens ?? defaultCacheTokens;
108
+ const bindRequestHeaders = (shapeId) => providerHooks.get(shapeId)?.requestHeaders;
108
109
  const agentSurface = {
109
110
  llm: createLlmFacade({ list: ctx.list, call: ctx.call }),
110
111
  providers: {
@@ -322,6 +323,7 @@ export default function agentBackend(ctx) {
322
323
  baseURL: p.baseURL,
323
324
  buildReasoningParams: bindReasoning(shapeId, modelId),
324
325
  extractCachedTokens: bindCacheTokens(shapeId),
326
+ buildRequestHeaders: bindRequestHeaders(shapeId),
325
327
  };
326
328
  };
327
329
  ctx.define("agent:get-models", () => buildModels());
@@ -365,12 +367,14 @@ export default function agentBackend(ctx) {
365
367
  bus.emit("config:switch-model", { id: pendingModel, provider: pendingProvider });
366
368
  }
367
369
  });
368
- bus.on("provider:configure", ({ id, reasoningParams, cacheTokens }) => {
370
+ bus.on("provider:configure", ({ id, reasoningParams, cacheTokens, requestHeaders }) => {
369
371
  const prev = providerHooks.get(id) ?? {};
370
372
  if (reasoningParams !== undefined)
371
373
  prev.reasoningParams = reasoningParams;
372
374
  if (cacheTokens !== undefined)
373
375
  prev.cacheTokens = cacheTokens;
376
+ if (requestHeaders !== undefined)
377
+ prev.requestHeaders = requestHeaders;
374
378
  providerHooks.set(id, prev);
375
379
  });
376
380
  bus.on("core:extensions-loaded", ({ names }) => {
@@ -39,6 +39,8 @@ export type StreamOpts = {
39
39
  model?: string;
40
40
  max_tokens?: number;
41
41
  signal?: AbortSignal;
42
+ /** Per-request transport headers, forwarded to the SDK (not request body). */
43
+ headers?: Record<string, string>;
42
44
  } & Record<string, unknown>;
43
45
  export type CompleteOpts = {
44
46
  messages: ChatCompletionMessageParam[];
@@ -43,7 +43,7 @@ export class LlmClient {
43
43
  this.model = newConfig.model;
44
44
  }
45
45
  stream(opts) {
46
- const { signal, messages, tools, model, max_tokens, ...rest } = opts;
46
+ const { signal, headers, messages, tools, model, max_tokens, ...rest } = opts;
47
47
  const body = {
48
48
  ...rest,
49
49
  model: model ?? this.model,
@@ -53,7 +53,7 @@ export class LlmClient {
53
53
  stream: true,
54
54
  stream_options: { include_usage: true },
55
55
  };
56
- return this.client.chat.completions.create(body, { signal });
56
+ return this.client.chat.completions.create(body, { signal, headers });
57
57
  }
58
58
  async complete(opts) {
59
59
  const { messages, model, max_tokens, ...rest } = opts;
@@ -24,7 +24,17 @@ function toModalities(input) {
24
24
  }
25
25
  export default function activate(ctx) {
26
26
  const apiKey = resolveApiKey("openrouter").key;
27
- ctx.agent.providers.configure("openrouter", { reasoningParams: buildReasoningParams });
27
+ ctx.agent.providers.configure("openrouter", {
28
+ reasoningParams: buildReasoningParams,
29
+ // x-session-id pins sticky provider routing across turns so prompt caches
30
+ // stay warm even when compaction rewrites the opening messages.
31
+ requestHeaders: ({ sessionId }) => {
32
+ const headers = {};
33
+ if (sessionId)
34
+ headers["x-session-id"] = sessionId;
35
+ return headers;
36
+ },
37
+ });
28
38
  ctx.agent.providers.register({
29
39
  id: "openrouter",
30
40
  apiKey: apiKey ?? undefined,
@@ -40,8 +40,16 @@ export const bashStrategy = {
40
40
  ' [[ $__agent_sh_preexec_ran == 1 ]] && return',
41
41
  ' [[ -n $COMP_LINE ]] && return',
42
42
  " __agent_sh_preexec_ran=1",
43
- " local this_cmd",
44
- ` this_cmd=$(HISTTIMEFORMAT='' builtin history 1 | command sed 's/^ *[0-9]* *//')`,
43
+ " local this_cmd hist_cmd",
44
+ ` hist_cmd=$(HISTTIMEFORMAT='' builtin history 1 | command sed 's/^ *[0-9]* *//')`,
45
+ " # history 1 carries the full typed line but goes stale when the user's",
46
+ " # PROMPT_COMMAND reloads history (history -c/-r). Trust it only when it",
47
+ " # matches the command bash is about to run; else use $BASH_COMMAND.",
48
+ ' if [[ -n $hist_cmd && $hist_cmd == "$BASH_COMMAND"* ]]; then',
49
+ " this_cmd=$hist_cmd",
50
+ " else",
51
+ " this_cmd=$BASH_COMMAND",
52
+ " fi",
45
53
  ` printf '\\e]9997;${instanceTag};%s\\a' "$this_cmd"`,
46
54
  "}",
47
55
  "trap '__agent_sh_emit_preexec' DEBUG",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guanyilun/ashi",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "Ash in an interactive TUI — agent-sh's built-in agent without the shell underneath",
5
5
  "type": "module",
6
6
  "main": "dist/cli.js",
@@ -38,7 +38,9 @@ export class AssistantMessage {
38
38
  if (this.buffer === "") this.buffer = " ";
39
39
  const blocks = this.transform([{ type: "text", text: this.buffer }]);
40
40
  if (blocks.every((b) => b.type === "text")) {
41
- this.md.setText(stripTrailing(this.buffer));
41
+ // Render the transformed text, not the raw buffer — transforms may rewrite
42
+ // content in place (e.g. inline-image sentinels) while staying all-text.
43
+ this.md.setText(stripTrailing(blocks.map((b) => (b.type === "text" ? b.text : "")).join("")));
42
44
  return;
43
45
  }
44
46
  this.rebuild(blocks);
@@ -34,6 +34,7 @@ import { registerCapture, type Capture } from "./capture.js";
34
34
  import { registerRenderDefaults } from "./hooks.js";
35
35
  import { registerDefaultSchemaRenderers } from "./default-schema-renderers.js";
36
36
  import { createPiTuiRenderer } from "./renderers/pi-tui/index.js";
37
+ import { registerInlineImage, supportsInlineImages } from "./renderers/pi-tui/inline-image.js";
37
38
  import type { Renderer } from "./renderer.js";
38
39
  import { loadRendererPreference } from "./display-config.js";
39
40
  import { applyOutputMode } from "./terminal-mode.js";
@@ -177,6 +178,8 @@ async function main(): Promise<void> {
177
178
 
178
179
  const ctx = core.extensionContext({ quit: cleanup });
179
180
 
181
+ ctx.define("session:current-id", () => store.current().id);
182
+
180
183
  activateAgent(ctx);
181
184
  activateShellContext(ctx);
182
185
  await loadBuiltinExtensions(ctx);
@@ -238,6 +241,11 @@ async function main(): Promise<void> {
238
241
  registerRenderDefaults(ctx, renderer);
239
242
  registerDefaultSchemaRenderers(ctx);
240
243
 
244
+ // Handler presence is how producers detect that inline images are available.
245
+ if (rendererName === "pi-tui" && supportsInlineImages()) {
246
+ ctx.define("ashi:inline-image:register", (png: Buffer) => registerInlineImage(png));
247
+ }
248
+
241
249
  ctx.advise("system-prompt:frontend", (next) => {
242
250
  const base = (next() as string) ?? "";
243
251
  return base ? `${base}\n\n${ASHI_SURFACE}` : ASHI_SURFACE;
@@ -399,7 +399,7 @@ export function mountAshi(
399
399
  app.requestRender();
400
400
  return;
401
401
  }
402
- pendingUserShell.push({ private: !!opts?.private });
402
+ pendingUserShell.push({ private: !!opts?.private, command: line });
403
403
  if (opts?.private) bus.emit("shell:user-exec-exclude-next", {});
404
404
  bus.emit("shell:pty-write", { data: line + "\n" });
405
405
  };
@@ -816,12 +816,13 @@ export function mountAshi(
816
816
  bus.on("shell:foreground-busy", ({ busy }) => { shellForegroundBusy = busy; });
817
817
 
818
818
  let activeUserShell: { pair: ToolPair; command: string; isPrivate: boolean } | null = null;
819
- bus.on("shell:command-start", ({ command }) => {
819
+ bus.on("shell:command-start", () => {
820
820
  if (agentShellActive) return;
821
821
  const intent = pendingUserShell.consume();
822
822
  if (!intent) return;
823
823
  finalizeThinking();
824
824
  if (activeAssistant) { activeAssistant.finalize(); activeAssistant = null; }
825
+ const command = intent.command;
825
826
  const isPrivate = intent.private;
826
827
  const name = isPrivate ? "user_bash_private" : "user_bash";
827
828
  const pair = renderToolPair({
@@ -861,7 +862,7 @@ export function mountAshi(
861
862
  // Drain shell queue before queries so its output lands in the next turn's <shell_events>.
862
863
  while (queuedShellLines.length > 0) {
863
864
  const item = queuedShellLines.shift()!;
864
- pendingUserShell.push({ private: item.private });
865
+ pendingUserShell.push({ private: item.private, command: item.line });
865
866
  if (item.private) bus.emit("shell:user-exec-exclude-next", {});
866
867
  bus.emit("shell:pty-write", { data: item.line + "\n" });
867
868
  }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Inline images in markdown text via the kitty/Ghostty Unicode placeholder
3
+ * protocol. The image is transmitted out-of-band (cursor-neutral); the frame
4
+ * carries only `cols` width-1 placeholder cells, so the renderer's wrap/measure
5
+ * math is unaffected and the line is never treated as an image line. kitty-only.
6
+ */
7
+ import {
8
+ allocateImageId,
9
+ getCapabilities,
10
+ getCellDimensions,
11
+ getImageDimensions,
12
+ } from "@earendil-works/pi-tui";
13
+
14
+ export const SENTINEL_RE = /\x01LI:(\d+)\x01/g;
15
+ export const PLACEHOLDER = String.fromCodePoint(0x10eeee);
16
+
17
+ // kitty rowcolumn-diacritics: the Nth entry encodes row/column index N
18
+ // (gen/rowcolumn-diacritics.txt in the kitty source).
19
+ const DIACRITICS = [0x0305,0x030D,0x030E,0x0310,0x0312,0x033D,0x033E,0x033F,0x0346,0x034A,0x034B,0x034C,0x0350,0x0351,0x0352,0x0357,0x035B,0x0363,0x0364,0x0365,0x0366,0x0367,0x0368,0x0369,0x036A,0x036B,0x036C,0x036D,0x036E,0x036F,0x0483,0x0484,0x0485,0x0486,0x0487,0x0592,0x0593,0x0594,0x0595,0x0597,0x0598,0x0599,0x059C,0x059D,0x059E,0x059F,0x05A0,0x05A1,0x05A8,0x05A9,0x05AB,0x05AC,0x05AF,0x05C4,0x0610,0x0611,0x0612,0x0613,0x0614,0x0615,0x0616,0x0617,0x0657,0x0658,0x0659,0x065A,0x065B,0x065D,0x065E,0x06D6,0x06D7,0x06D8,0x06D9,0x06DA,0x06DB,0x06DC,0x06DF,0x06E0,0x06E1,0x06E2,0x06E4,0x06E7,0x06E8,0x06EB,0x06EC,0x0730,0x0732,0x0733,0x0735,0x0736,0x073A,0x073D,0x073F,0x0740,0x0741,0x0743,0x0745,0x0747,0x0749,0x074A,0x07EB,0x07EC,0x07ED,0x07EE,0x07EF,0x07F0,0x07F1,0x07F3,0x0816,0x0817,0x0818,0x0819,0x081B,0x081C,0x081D,0x081E,0x081F,0x0820,0x0821,0x0822,0x0823,0x0825,0x0826,0x0827,0x0829,0x082A,0x082B,0x082C,0x082D,0x0951,0x0953,0x0954,0x0F82,0x0F83,0x0F86,0x0F87,0x135D,0x135E,0x135F,0x17DD,0x193A,0x1A17,0x1A75,0x1A76,0x1A77,0x1A78,0x1A79,0x1A7A,0x1A7B,0x1A7C,0x1B6B,0x1B6D,0x1B6E,0x1B6F,0x1B70,0x1B71,0x1B72,0x1B73,0x1CD0,0x1CD1,0x1CD2,0x1CDA,0x1CDB,0x1CE0,0x1DC0,0x1DC1,0x1DC3,0x1DC4,0x1DC5,0x1DC6,0x1DC7,0x1DC8,0x1DC9,0x1DCB,0x1DCC,0x1DD1,0x1DD2,0x1DD3,0x1DD4,0x1DD5,0x1DD6,0x1DD7,0x1DD8,0x1DD9,0x1DDA,0x1DDB,0x1DDC,0x1DDD,0x1DDE,0x1DDF,0x1DE0,0x1DE1,0x1DE2,0x1DE3,0x1DE4,0x1DE5,0x1DE6,0x1DFE,0x20D0,0x20D1,0x20D4,0x20D5,0x20D6,0x20D7,0x20DB,0x20DC,0x20E1,0x20E7,0x20E9,0x20F0,0x2CEF,0x2CF0,0x2CF1,0x2DE0,0x2DE1,0x2DE2,0x2DE3,0x2DE4,0x2DE5,0x2DE6,0x2DE7,0x2DE8,0x2DE9,0x2DEA,0x2DEB,0x2DEC,0x2DED,0x2DEE,0x2DEF,0x2DF0,0x2DF1,0x2DF2,0x2DF3,0x2DF4,0x2DF5,0x2DF6,0x2DF7,0x2DF8,0x2DF9,0x2DFA,0x2DFB,0x2DFC,0x2DFD,0x2DFE,0x2DFF,0xA66F,0xA67C,0xA67D,0xA6F0,0xA6F1,0xA8E0,0xA8E1,0xA8E2,0xA8E3,0xA8E4,0xA8E5,0xA8E6,0xA8E7,0xA8E8,0xA8E9,0xA8EA,0xA8EB,0xA8EC,0xA8ED,0xA8EE,0xA8EF,0xA8F0,0xA8F1,0xAAB0,0xAAB2,0xAAB3,0xAAB7,0xAAB8,0xAABE,0xAABF,0xAAC1,0xFE20,0xFE21,0xFE22,0xFE23,0xFE24,0xFE25,0xFE26,0x10A0F,0x10A38,0x1D185,0x1D186,0x1D187,0x1D188,0x1D189,0x1D1AA,0x1D1AB,0x1D1AC,0x1D1AD,0x1D242,0x1D243,0x1D244];
20
+
21
+ const ESC = "\x1b";
22
+ const diacritic = (i: number): string =>
23
+ String.fromCodePoint(DIACRITICS[Math.min(i, DIACRITICS.length - 1)]!);
24
+
25
+ interface Entry {
26
+ base64: string;
27
+ widthPx: number;
28
+ heightPx: number;
29
+ transmitted: boolean;
30
+ placedCols: number;
31
+ }
32
+
33
+ const registry = new Map<number, Entry>();
34
+
35
+ export function supportsInlineImages(): boolean {
36
+ return getCapabilities().images === "kitty";
37
+ }
38
+
39
+ export function registerInlineImage(png: Buffer): number | null {
40
+ if (!supportsInlineImages()) return null;
41
+ const base64 = png.toString("base64");
42
+ const dims = getImageDimensions(base64, "image/png");
43
+ if (!dims || dims.heightPx <= 0) return null;
44
+ const id = allocateImageId();
45
+ registry.set(id, { base64, widthPx: dims.widthPx, heightPx: dims.heightPx, transmitted: false, placedCols: 0 });
46
+ return id;
47
+ }
48
+
49
+ const maxInlineCols = (): number => Math.max(8, (process.stdout.columns ?? 80) - 4);
50
+
51
+ export function inlineCols(id: number, maxCols = maxInlineCols()): number | null {
52
+ const e = registry.get(id);
53
+ if (!e) return null;
54
+ const cell = getCellDimensions();
55
+ const cols = Math.round((e.widthPx / e.heightPx) * (cell.heightPx / cell.widthPx));
56
+ // Column index is carried by a diacritic, so cols can't exceed the table.
57
+ return Math.max(1, Math.min(maxCols, DIACRITICS.length, cols));
58
+ }
59
+
60
+ // Transmits once, then re-places only when cols changes (resize). `write` must
61
+ // target the same stream as the composed frame so the data lands before the cells.
62
+ export function emitInlineImage(id: number, cols: number, write: (s: string) => void): void {
63
+ const e = registry.get(id);
64
+ if (!e) return;
65
+ if (!e.transmitted) {
66
+ e.transmitted = true;
67
+ e.placedCols = cols;
68
+ const b64 = e.base64;
69
+ const CHUNK = 4096;
70
+ for (let i = 0; i < b64.length; i += CHUNK) {
71
+ const chunk = b64.slice(i, i + CHUNK);
72
+ const last = i + CHUNK >= b64.length;
73
+ write(
74
+ i === 0
75
+ ? `${ESC}_Gq=2,a=T,U=1,f=100,t=d,i=${id},c=${cols},r=1,m=${last ? 0 : 1};${chunk}${ESC}\\`
76
+ : `${ESC}_Gq=2,m=${last ? 0 : 1};${chunk}${ESC}\\`,
77
+ );
78
+ }
79
+ } else if (e.placedCols !== cols) {
80
+ e.placedCols = cols;
81
+ write(`${ESC}_Gq=2,a=p,U=1,i=${id},c=${cols},r=1${ESC}\\`);
82
+ }
83
+ }
84
+
85
+ // Each cell encodes the id's low 24 bits in the fg colour and its high byte in the
86
+ // third diacritic (row, column, id-high) — allocateImageId() ids use all 32 bits.
87
+ // `colStart` offsets the column index so an image split across lines (wrap) keeps
88
+ // continuous columns instead of repeating its left edge.
89
+ export function inlinePlaceholder(id: number, count: number, colStart = 0): string {
90
+ const hi = diacritic((id >> 24) & 255);
91
+ let s = `${ESC}[38;2;${(id >> 16) & 255};${(id >> 8) & 255};${id & 255}m`;
92
+ for (let j = 0; j < count; j++) s += PLACEHOLDER + diacritic(0) + diacritic(colStart + j) + hi;
93
+ return s + `${ESC}[39m`;
94
+ }
95
+
96
+ export interface InlineItem {
97
+ id: number;
98
+ cols: number;
99
+ }
100
+
101
+ // Repaint the reserved PLACEHOLDER runs as real kitty placeholder cells. Cells are
102
+ // partitioned by each image's reserved `cols` rather than by contiguous run, so
103
+ // adjacent images (`$a$$b$` → one fused run) and an image the wrapper split across
104
+ // lines both keep their ids aligned. `transmit` fires once per image, when its
105
+ // first cell is placed. Pure apart from the injected `transmit` callback.
106
+ export function paintInlineImages(
107
+ lines: string[],
108
+ items: InlineItem[],
109
+ transmit: (id: number, cols: number) => void,
110
+ ): string[] {
111
+ if (items.length === 0) return lines;
112
+ let k = 0; // index of the image currently being placed
113
+ let placed = 0; // cells of image k already emitted across prior lines
114
+ return lines.map((line) => {
115
+ if (k >= items.length || !line.includes(PLACEHOLDER)) return line;
116
+ let out = "";
117
+ let i = 0;
118
+ while (i < line.length) {
119
+ const ch = String.fromCodePoint(line.codePointAt(i)!);
120
+ if (ch === PLACEHOLDER && k < items.length) {
121
+ const item = items[k]!;
122
+ let count = 0;
123
+ while (
124
+ i < line.length &&
125
+ String.fromCodePoint(line.codePointAt(i)!) === PLACEHOLDER &&
126
+ placed + count < item.cols
127
+ ) {
128
+ count++;
129
+ i += PLACEHOLDER.length;
130
+ }
131
+ if (placed === 0) transmit(item.id, item.cols);
132
+ out += inlinePlaceholder(item.id, count, placed);
133
+ placed += count;
134
+ if (placed >= item.cols) {
135
+ k++;
136
+ placed = 0;
137
+ }
138
+ } else {
139
+ out += ch;
140
+ i += ch.length;
141
+ }
142
+ }
143
+ return out;
144
+ });
145
+ }
@@ -14,6 +14,14 @@ import {
14
14
  } from "@earendil-works/pi-tui";
15
15
  import { theme } from "../../theme.js";
16
16
  import { markdownTheme } from "./theme-adapters.js";
17
+ import {
18
+ emitInlineImage,
19
+ inlineCols,
20
+ paintInlineImages,
21
+ PLACEHOLDER,
22
+ SENTINEL_RE,
23
+ type InlineItem,
24
+ } from "./inline-image.js";
17
25
  import type {
18
26
  ContainerView,
19
27
  MarkdownOptions,
@@ -118,6 +126,48 @@ class ZonedMarkdown extends Markdown {
118
126
  }
119
127
  }
120
128
 
129
+ // Each sentinel becomes a bare run of `cols` placeholder cells so the wrapper
130
+ // reserves the right width; render() later paints the image into that run.
131
+ // Exported for tests: with no sentinels (latex-images off / non-kitty terminal,
132
+ // where the sentinel producer never runs) this is a pass-through.
133
+ export function reserveSentinels(full: string): { display: string; items: InlineItem[] } {
134
+ const items: InlineItem[] = [];
135
+ const display = full.replace(SENTINEL_RE, (_m, idStr) => {
136
+ const cols = inlineCols(Number(idStr));
137
+ if (cols === null) return "";
138
+ items.push({ id: Number(idStr), cols });
139
+ return PLACEHOLDER.repeat(cols);
140
+ });
141
+ return { display, items };
142
+ }
143
+
144
+ function injectInlineImages(lines: string[], items: InlineItem[]): string[] {
145
+ const write = (s: string): boolean => process.stdout.write(s);
146
+ return paintInlineImages(lines, items, (id, cols) => emitInlineImage(id, cols, write));
147
+ }
148
+
149
+ type MarkdownCtor = new (...args: ConstructorParameters<typeof Markdown>) => Markdown;
150
+
151
+ // Subclass that flows inline images where the text carries sentinels; a no-op
152
+ // superset of the base when none are present.
153
+ function withInlineImages<T extends MarkdownCtor>(Base: T): T {
154
+ class InlineImageMarkdown extends (Base as MarkdownCtor) {
155
+ private inlineItems: InlineItem[] = [];
156
+ override setText(full: string): void {
157
+ const { display, items } = reserveSentinels(full);
158
+ this.inlineItems = items;
159
+ super.setText(display);
160
+ }
161
+ override render(width: number): string[] {
162
+ return injectInlineImages(super.render(width), this.inlineItems);
163
+ }
164
+ }
165
+ return InlineImageMarkdown as unknown as T;
166
+ }
167
+
168
+ const InlineMarkdown = withInlineImages(Markdown);
169
+ const InlineZonedMarkdown = withInlineImages(ZonedMarkdown);
170
+
121
171
  class FooterSlot extends Container {
122
172
  constructor(private readonly hasContentAbove: () => boolean) {
123
173
  super();
@@ -157,7 +207,7 @@ export function createNodes(opts: { imageScale?: number } = {}): RenderNodes {
157
207
  opts?.color || opts?.bgColor
158
208
  ? { ...(opts.color ? { color: opts.color } : {}), ...(opts.bgColor ? { bgColor: opts.bgColor } : {}) }
159
209
  : undefined;
160
- const Ctor = opts?.osc133Zones ? ZonedMarkdown : Markdown;
210
+ const Ctor = opts?.osc133Zones ? InlineZonedMarkdown : InlineMarkdown;
161
211
  const md = new Ctor("", opts?.paddingX ?? 0, opts?.paddingY ?? 0, markdownTheme(), colorOpts);
162
212
  const view: MarkdownView = {
163
213
  node: asNode(md),
@@ -1,7 +1,10 @@
1
1
  /** Pending intents for ashi-issued shell pty-writes. shell:command-start fires
2
- * for any OSC 9997 — orphans (bash DEBUG-trap noise) are dropped on consume. */
2
+ * for any OSC 9997 — orphans (bash DEBUG-trap noise) are dropped on consume.
3
+ * Carries the command ashi sent so the rendered label is the exact text we
4
+ * wrote to the pty, not whatever the shell echoes back. */
3
5
  export interface UserShellIntent {
4
6
  private: boolean;
7
+ command: string;
5
8
  }
6
9
 
7
10
  export class UserShellIntents {
@@ -22,7 +22,10 @@ import * as path from "node:path";
22
22
  import type { ExtensionContext } from "agent-sh/types";
23
23
 
24
24
  // Settings loaded in activate() via ctx.getExtensionSettings
25
- let config = { dpi: 300, fgColor: "d4d4d4" };
25
+ // inlineScale: inline math font vs ~1 em of text (1.0 ≈ text, <1 smaller).
26
+ let config = { dpi: 300, fgColor: "d4d4d4", inlineScale: 1.0 };
27
+
28
+ let magickBin: string | null = null;
26
29
 
27
30
  /** Encode PNG as iTerm2 or Kitty inline image escape sequence. */
28
31
  function encodeImage(data: Buffer): string {
@@ -56,6 +59,17 @@ $\\displaystyle ${equation}$
56
59
  \\end{document}
57
60
  `;
58
61
 
62
+ // Inline equations: text-style (no \displaystyle) so sizing matches a text line.
63
+ const LATEX_INLINE_TEMPLATE = (equation: string, fg: string) => `
64
+ \\documentclass[border=1pt]{standalone}
65
+ \\usepackage{amsmath,amssymb,amsfonts}
66
+ \\usepackage{xcolor}
67
+ \\begin{document}
68
+ \\color[HTML]{${fg}}
69
+ $ ${equation} $
70
+ \\end{document}
71
+ `;
72
+
59
73
  let tmpDir: string | null = null;
60
74
  let renderCounter = 0;
61
75
 
@@ -66,7 +80,10 @@ function ensureTmpDir(): string {
66
80
  return tmpDir;
67
81
  }
68
82
 
69
- function renderEquation(equation: string): Buffer | null {
83
+ function renderEquation(
84
+ equation: string,
85
+ template: (eq: string, fg: string) => string,
86
+ ): Buffer | null {
70
87
  const dir = ensureTmpDir();
71
88
  const idx = renderCounter++;
72
89
  const texPath = path.join(dir, `eq${idx}.tex`);
@@ -74,7 +91,7 @@ function renderEquation(equation: string): Buffer | null {
74
91
  const pngPath = path.join(dir, `eq${idx}.png`);
75
92
 
76
93
  try {
77
- fs.writeFileSync(texPath, LATEX_TEMPLATE(equation, config.fgColor));
94
+ fs.writeFileSync(texPath, template(equation, config.fgColor));
78
95
 
79
96
  execSync(
80
97
  `latex -interaction=nonstopmode -output-directory="${dir}" "${texPath}"`,
@@ -98,10 +115,50 @@ function renderEquation(equation: string): Buffer | null {
98
115
 
99
116
  const equationCache = new Map<string, Buffer | null>();
100
117
  function renderEquationCached(equation: string): Buffer | null {
101
- if (!equationCache.has(equation)) {
102
- equationCache.set(equation, renderEquation(equation));
118
+ const key = `d:${equation}`;
119
+ if (!equationCache.has(key)) {
120
+ equationCache.set(key, renderEquation(equation, LATEX_TEMPLATE));
121
+ }
122
+ return equationCache.get(key) ?? null;
123
+ }
124
+
125
+ // 1 TeX pt = 1/72.27 inch; standalone's default body font is 10pt.
126
+ const PT_PER_INCH = 72.27;
127
+
128
+ // Pad each equation's height up to a shared ~1 em reference (transparent, centered)
129
+ // so short and tall expressions render at the same font — only their heights differ.
130
+ // Cols are derived downstream from this padded height. No-op for already-tall content.
131
+ function normalizeInlineHeight(buf: Buffer): Buffer {
132
+ if (!magickBin) return buf;
133
+ const w = buf.readUInt32BE(16);
134
+ const h = buf.readUInt32BE(20);
135
+ const emPx = (config.dpi * 10) / PT_PER_INCH;
136
+ const scale = config.inlineScale > 0 ? config.inlineScale : 1;
137
+ const targetH = Math.round(emPx / scale);
138
+ if (h >= targetH) return buf;
139
+ const dir = ensureTmpDir();
140
+ const idx = renderCounter++;
141
+ const inPath = path.join(dir, `pad${idx}-in.png`);
142
+ const outPath = path.join(dir, `pad${idx}-out.png`);
143
+ try {
144
+ fs.writeFileSync(inPath, buf);
145
+ execSync(
146
+ `${magickBin} "${inPath}" -background none -gravity center -extent ${w}x${targetH} "${outPath}"`,
147
+ { timeout: 10000, stdio: "pipe" },
148
+ );
149
+ return fs.readFileSync(outPath);
150
+ } catch {
151
+ return buf;
103
152
  }
104
- return equationCache.get(equation) ?? null;
153
+ }
154
+
155
+ function renderInlineCached(equation: string): Buffer | null {
156
+ const key = `i:${equation}`;
157
+ if (!equationCache.has(key)) {
158
+ const png = renderEquation(equation, LATEX_INLINE_TEMPLATE);
159
+ equationCache.set(key, png ? normalizeInlineHeight(png) : null);
160
+ }
161
+ return equationCache.get(key) ?? null;
105
162
  }
106
163
 
107
164
  const EQ_DELIM = "$$";
@@ -132,6 +189,65 @@ function splitEquations(text: string): Block[] {
132
189
  return out;
133
190
  }
134
191
 
192
+ // ── Inline math ($…$) ────────────────────────────────────────────
193
+
194
+ // KaTeX-style `$…$` rules so prose/currency don't false-match: no space after the
195
+ // opening `$`; no space before the closing `$` and no digit after it; one line; \$.
196
+ export function matchInline(text: string, open: number): { eq: string; end: number } | null {
197
+ if (open + 1 >= text.length || /\s/.test(text[open + 1]!)) return null;
198
+ for (let j = open + 1; j < text.length; j++) {
199
+ const ch = text[j]!;
200
+ if (ch === "\n") return null;
201
+ if (ch === "\\") { j++; continue; }
202
+ if (ch !== "$") continue;
203
+ if (/\s/.test(text[j - 1]!)) continue;
204
+ if (/[0-9]/.test(text[j + 1] ?? "")) continue;
205
+ const eq = text.slice(open + 1, j);
206
+ return eq.trim() === "" ? null : { eq, end: j + 1 };
207
+ }
208
+ return null;
209
+ }
210
+
211
+ // Replace inline `$…$` with `\x01LI:<id>\x01` sentinels; leaves escapes and inline
212
+ // code spans untouched, and falls back to literal text when register() returns null.
213
+ export function replaceInline(text: string, register: (eq: string) => number | null): string {
214
+ let out = "";
215
+ let i = 0;
216
+ while (i < text.length) {
217
+ const c = text[i]!;
218
+ if (c === "\\") { out += text.slice(i, i + 2); i += 2; continue; }
219
+ if (c === "`") {
220
+ // Code span: a run of N backticks closes on the next run of exactly N.
221
+ // An unmatched opening run is a literal backtick — keep scanning after it.
222
+ let n = 1;
223
+ while (text[i + n] === "`") n++;
224
+ let j = i + n;
225
+ let close = -1;
226
+ while (j < text.length) {
227
+ if (text[j] === "`") {
228
+ let m = 1;
229
+ while (text[j + m] === "`") m++;
230
+ if (m === n) { close = j; break; }
231
+ j += m;
232
+ } else j++;
233
+ }
234
+ if (close === -1) { out += text.slice(i, i + n); i += n; continue; }
235
+ out += text.slice(i, close + n); i = close + n; continue;
236
+ }
237
+ if (c === "$" && text[i + 1] !== "$") {
238
+ const m = matchInline(text, i);
239
+ if (m) {
240
+ const id = register(m.eq);
241
+ out += id === null ? text.slice(i, m.end) : `\x01LI:${id}\x01`;
242
+ i = m.end;
243
+ continue;
244
+ }
245
+ }
246
+ out += c; i++;
247
+ }
248
+ return out;
249
+ }
250
+
135
251
  // ── Extension entry point ────────────────────────────────────────
136
252
 
137
253
  export default function activate(ctx: ExtensionContext) {
@@ -151,6 +267,15 @@ export default function activate(ctx: ExtensionContext) {
151
267
  return;
152
268
  }
153
269
 
270
+ // ImageMagick is optional; only used to shrink inline glyphs (config.inlineScale).
271
+ for (const bin of ["magick", "convert"]) {
272
+ try {
273
+ execSync(`${bin} --version`, { stdio: "ignore", timeout: 3000 });
274
+ magickBin = bin;
275
+ break;
276
+ } catch { /* not installed */ }
277
+ }
278
+
154
279
  ctx.define("latex:render-equation", (equation: string): Buffer | null => renderEquationCached(equation));
155
280
 
156
281
  if (ctx.shell) {
@@ -172,14 +297,34 @@ export default function activate(ctx: ExtensionContext) {
172
297
  ctx.call("render:image", png);
173
298
  });
174
299
  } else {
300
+ // Cache the id per equation so each image is registered (and transmitted) once.
301
+ const inlineIds = new Map<string, number>();
302
+ const registerInline = (eq: string): number | null => {
303
+ const cached = inlineIds.get(eq);
304
+ if (cached !== undefined) return cached;
305
+ const png = renderInlineCached(eq);
306
+ if (!png) return null;
307
+ const id = ctx.call("ashi:inline-image:register", png) as number | null;
308
+ if (typeof id === "number") inlineIds.set(eq, id);
309
+ return typeof id === "number" ? id : null;
310
+ };
311
+
175
312
  (bus.onPipe as unknown as (e: string, fn: (p: ContentPipe) => ContentPipe) => void)(
176
313
  "render:assistant-content",
177
314
  (payload) => {
178
315
  // Can't show images reliably → leave $$…$$ as text.
179
316
  if (!payload.images) return payload;
317
+ const canInline = ctx.list().includes("ashi:inline-image:register");
180
318
  return {
181
319
  ...payload,
182
- blocks: payload.blocks.flatMap((b) => (b.type === "text" ? splitEquations(b.text) : [b])),
320
+ blocks: payload.blocks.flatMap((b) => {
321
+ if (b.type !== "text") return [b];
322
+ return splitEquations(b.text).map((blk) =>
323
+ blk.type === "text" && canInline
324
+ ? { type: "text" as const, text: replaceInline(blk.text, registerInline) }
325
+ : blk,
326
+ );
327
+ }),
183
328
  };
184
329
  },
185
330
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sh",
3
- "version": "0.15.7",
3
+ "version": "0.15.8",
4
4
  "description": "A composable agent runtime — pair any frontend with any agent backend over one shared extension layer",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -462,6 +462,17 @@ export class AgentLoop implements AgentBackend {
462
462
  }
463
463
 
464
464
 
465
+ /** Resume-stable conversation id from the frontend (e.g. ashi); undefined
466
+ * when the frontend tracks no session. */
467
+ private currentSessionId(): string | undefined {
468
+ try {
469
+ const id = this.handlers.call("session:current-id");
470
+ return typeof id === "string" && id ? id : undefined;
471
+ } catch {
472
+ return undefined;
473
+ }
474
+ }
475
+
465
476
  private resolveEndpoint(m: Model): ModelEndpoint | undefined {
466
477
  try {
467
478
  return this.handlers.call("agent:resolve-endpoint", { provider: m.provider, id: m.id }) as ModelEndpoint | undefined;
@@ -1439,7 +1450,12 @@ export class AgentLoop implements AgentBackend {
1439
1450
  };
1440
1451
  this.bus.emit("llm:request", requestParams);
1441
1452
 
1442
- const stream = await this.llmClient.stream({ ...requestParams, signal });
1453
+ const headers = this.activeEndpoint?.buildRequestHeaders?.({ sessionId: this.currentSessionId() });
1454
+ const stream = await this.llmClient.stream({
1455
+ ...requestParams,
1456
+ signal,
1457
+ ...(headers && Object.keys(headers).length ? { headers } : {}),
1458
+ });
1443
1459
 
1444
1460
  try {
1445
1461
  for await (const chunk of stream) {
@@ -22,6 +22,7 @@ declare module "../core/event-bus.js" {
22
22
  id: string;
23
23
  reasoningParams?: (level: string, model?: string) => Record<string, unknown>;
24
24
  cacheTokens?: (usage: Record<string, unknown>) => number | undefined;
25
+ requestHeaders?: (info: { sessionId?: string }) => Record<string, string>;
25
26
  };
26
27
 
27
28
  "agent:models-changed": Record<string, never>;
@@ -93,6 +93,7 @@ export interface ModelEndpoint {
93
93
  baseURL?: string;
94
94
  buildReasoningParams?: (level: string) => Record<string, unknown>;
95
95
  extractCachedTokens?: (usage: Record<string, unknown>) => number | undefined;
96
+ buildRequestHeaders?: (info: { sessionId?: string }) => Record<string, string>;
96
97
  }
97
98
 
98
99
  // ── Agent-host extension surface ─────────────────────────────────
@@ -111,6 +112,7 @@ export interface AgentSurface {
111
112
  configure: (id: string, opts: {
112
113
  reasoningParams?: (level: string, model?: string) => Record<string, unknown>;
113
114
  cacheTokens?: (usage: Record<string, unknown>) => number | undefined;
115
+ requestHeaders?: (info: { sessionId?: string }) => Record<string, string>;
114
116
  }) => void;
115
117
  };
116
118
 
@@ -128,6 +128,7 @@ export default function agentBackend(ctx: ExtensionContext): void {
128
128
  const providerHooks = new Map<string, {
129
129
  reasoningParams?: (level: string, model?: string) => Record<string, unknown>;
130
130
  cacheTokens?: (usage: Record<string, unknown>) => number | undefined;
131
+ requestHeaders?: (info: { sessionId?: string }) => Record<string, string>;
131
132
  }>();
132
133
 
133
134
  // Bakes model id so ModelEndpoint.buildReasoningParams keeps its (level) signature.
@@ -139,6 +140,9 @@ export default function agentBackend(ctx: ExtensionContext): void {
139
140
  const bindCacheTokens = (shapeId: string) =>
140
141
  providerHooks.get(shapeId)?.cacheTokens ?? defaultCacheTokens;
141
142
 
143
+ const bindRequestHeaders = (shapeId: string) =>
144
+ providerHooks.get(shapeId)?.requestHeaders;
145
+
142
146
  const agentSurface: AgentSurface = {
143
147
  llm: createLlmFacade({ list: ctx.list, call: ctx.call }),
144
148
  providers: {
@@ -345,6 +349,7 @@ export default function agentBackend(ctx: ExtensionContext): void {
345
349
  baseURL: p.baseURL,
346
350
  buildReasoningParams: bindReasoning(shapeId, modelId),
347
351
  extractCachedTokens: bindCacheTokens(shapeId),
352
+ buildRequestHeaders: bindRequestHeaders(shapeId),
348
353
  };
349
354
  };
350
355
 
@@ -388,10 +393,11 @@ export default function agentBackend(ctx: ExtensionContext): void {
388
393
  }
389
394
  });
390
395
 
391
- bus.on("provider:configure", ({ id, reasoningParams, cacheTokens }) => {
396
+ bus.on("provider:configure", ({ id, reasoningParams, cacheTokens, requestHeaders }) => {
392
397
  const prev = providerHooks.get(id) ?? {};
393
398
  if (reasoningParams !== undefined) prev.reasoningParams = reasoningParams;
394
399
  if (cacheTokens !== undefined) prev.cacheTokens = cacheTokens;
400
+ if (requestHeaders !== undefined) prev.requestHeaders = requestHeaders;
395
401
  providerHooks.set(id, prev);
396
402
  });
397
403
 
@@ -68,7 +68,7 @@ export class LlmClient {
68
68
  }
69
69
 
70
70
  stream(opts: StreamOpts) {
71
- const { signal, messages, tools, model, max_tokens, ...rest } = opts;
71
+ const { signal, headers, messages, tools, model, max_tokens, ...rest } = opts;
72
72
  const body = {
73
73
  ...rest,
74
74
  model: model ?? this.model,
@@ -78,7 +78,7 @@ export class LlmClient {
78
78
  stream: true as const,
79
79
  stream_options: { include_usage: true },
80
80
  };
81
- return this.client.chat.completions.create(body as ChatCompletionCreateParamsStreaming, { signal });
81
+ return this.client.chat.completions.create(body as ChatCompletionCreateParamsStreaming, { signal, headers });
82
82
  }
83
83
 
84
84
  async complete(opts: CompleteOpts): Promise<string> {
@@ -102,6 +102,8 @@ export type StreamOpts = {
102
102
  model?: string;
103
103
  max_tokens?: number;
104
104
  signal?: AbortSignal;
105
+ /** Per-request transport headers, forwarded to the SDK (not request body). */
106
+ headers?: Record<string, string>;
105
107
  } & Record<string, unknown>;
106
108
 
107
109
  export type CompleteOpts = {
@@ -42,7 +42,16 @@ function toModalities(input?: string[]): ("text" | "image")[] | undefined {
42
42
 
43
43
  export default function activate(ctx: AgentContext): void {
44
44
  const apiKey = resolveApiKey("openrouter").key;
45
- ctx.agent.providers.configure("openrouter", { reasoningParams: buildReasoningParams });
45
+ ctx.agent.providers.configure("openrouter", {
46
+ reasoningParams: buildReasoningParams,
47
+ // x-session-id pins sticky provider routing across turns so prompt caches
48
+ // stay warm even when compaction rewrites the opening messages.
49
+ requestHeaders: ({ sessionId }) => {
50
+ const headers: Record<string, string> = {};
51
+ if (sessionId) headers["x-session-id"] = sessionId;
52
+ return headers;
53
+ },
54
+ });
46
55
  ctx.agent.providers.register({
47
56
  id: "openrouter",
48
57
  apiKey: apiKey ?? undefined,
@@ -47,8 +47,16 @@ export const bashStrategy: ShellStrategy = {
47
47
  ' [[ $__agent_sh_preexec_ran == 1 ]] && return',
48
48
  ' [[ -n $COMP_LINE ]] && return',
49
49
  " __agent_sh_preexec_ran=1",
50
- " local this_cmd",
51
- ` this_cmd=$(HISTTIMEFORMAT='' builtin history 1 | command sed 's/^ *[0-9]* *//')`,
50
+ " local this_cmd hist_cmd",
51
+ ` hist_cmd=$(HISTTIMEFORMAT='' builtin history 1 | command sed 's/^ *[0-9]* *//')`,
52
+ " # history 1 carries the full typed line but goes stale when the user's",
53
+ " # PROMPT_COMMAND reloads history (history -c/-r). Trust it only when it",
54
+ " # matches the command bash is about to run; else use $BASH_COMMAND.",
55
+ ' if [[ -n $hist_cmd && $hist_cmd == "$BASH_COMMAND"* ]]; then',
56
+ " this_cmd=$hist_cmd",
57
+ " else",
58
+ " this_cmd=$BASH_COMMAND",
59
+ " fi",
52
60
  ` printf '\\e]9997;${instanceTag};%s\\a' "$this_cmd"`,
53
61
  "}",
54
62
  "trap '__agent_sh_emit_preexec' DEBUG",