agent-sh 0.12.10 → 0.12.11

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.
@@ -226,7 +226,10 @@ export class AgentLoop {
226
226
  this.abortController?.abort(e.silent ? "silent" : undefined);
227
227
  });
228
228
  on("config:switch-model", ({ model: target }) => {
229
- const idx = this.modes.findIndex((m) => m.model === target);
229
+ const atIdx = target.lastIndexOf("@");
230
+ const modelId = atIdx > 0 ? target.slice(0, atIdx) : target;
231
+ const providerHint = atIdx > 0 ? target.slice(atIdx + 1) : undefined;
232
+ const idx = this.modes.findIndex((m) => m.model === modelId && (!providerHint || m.provider === providerHint));
230
233
  if (idx === -1) {
231
234
  this.bus.emit("ui:error", { message: `Unknown model: ${target}` });
232
235
  return;
@@ -259,7 +262,8 @@ export class AgentLoop {
259
262
  });
260
263
  this.bus.onPipe("config:get-models", (payload) => {
261
264
  const models = this.modes.map((m) => ({ model: m.model, provider: m.provider ?? "" }));
262
- const active = this.modes[this.currentModeIndex]?.model ?? null;
265
+ const cur = this.modes[this.currentModeIndex];
266
+ const active = cur ? { model: cur.model, provider: cur.provider ?? "" } : null;
263
267
  return { models, active };
264
268
  });
265
269
  on("config:set-thinking", ({ level }) => {
@@ -279,7 +279,10 @@ export interface ShellEvents {
279
279
  model: string;
280
280
  provider: string;
281
281
  }[];
282
- active: string | null;
282
+ active: {
283
+ model: string;
284
+ provider: string;
285
+ } | null;
283
286
  };
284
287
  "config:set-thinking": {
285
288
  level: string;
@@ -37,11 +37,10 @@ export default function activate(ctx) {
37
37
  handler: (args) => {
38
38
  const name = args.trim();
39
39
  if (!name) {
40
- const { models, active } = bus.emitPipe("config:get-models", { models: [], active: null });
41
- const current = models.find((m) => m.model === active);
42
- const label = current
43
- ? `${current.model}${current.provider ? ` [${current.provider}]` : ""}`
44
- : active ?? "none";
40
+ const { active } = bus.emitPipe("config:get-models", { models: [], active: null });
41
+ const label = active
42
+ ? `${active.model}${active.provider ? ` [${active.provider}]` : ""}`
43
+ : "none";
45
44
  bus.emit("ui:info", { message: `Model: ${label}` });
46
45
  }
47
46
  else {
@@ -180,13 +179,20 @@ export default function activate(ctx) {
180
179
  return payload;
181
180
  const partial = (payload.commandArgs ?? "").toLowerCase();
182
181
  const { models, active } = bus.emitPipe("config:get-models", { models: [], active: null });
182
+ const counts = new Map();
183
+ for (const m of models)
184
+ counts.set(m.model, (counts.get(m.model) ?? 0) + 1);
183
185
  const items = models
184
186
  .filter((m) => m.model.toLowerCase().includes(partial))
185
187
  .slice(0, 15)
186
- .map((m) => ({
187
- name: `/model ${m.model}`,
188
- description: `${m.provider ? `[${m.provider}]` : ""}${m.model === active ? " (active)" : ""}`,
189
- }));
188
+ .map((m) => {
189
+ const ambiguous = (counts.get(m.model) ?? 0) > 1 && m.provider;
190
+ const qualified = ambiguous ? `${m.model}@${m.provider}` : m.model;
191
+ return {
192
+ name: `/model ${qualified}`,
193
+ description: `${m.provider ? `[${m.provider}]` : ""}${active && m.model === active.model && m.provider === active.provider ? " (active)" : ""}`,
194
+ };
195
+ });
190
196
  if (items.length === 0)
191
197
  return payload;
192
198
  return { ...payload, items: [...payload.items, ...items] };
package/dist/init.js CHANGED
@@ -37,9 +37,7 @@ const EXAMPLE_SETTINGS = {
37
37
  defaultModel: "llama3.3",
38
38
  },
39
39
  },
40
- extensions: [
41
- "./examples/extensions/openrouter.ts",
42
- ],
40
+ extensions: [],
43
41
  disabledBuiltins: [],
44
42
  disabledExtensions: [],
45
43
  };
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Ollama provider extension — local daemon and Ollama Cloud.
3
+ *
4
+ * OLLAMA_API_KEY → Ollama Cloud (https://ollama.com)
5
+ * OLLAMA_HOST → local host override (default http://localhost:11434)
6
+ *
7
+ * Catalog comes from /api/tags; per-model context length is fetched
8
+ * from /api/show (model_info["${arch}.context_length"]). Chat goes
9
+ * through the OpenAI-compatible /v1/chat/completions shim.
10
+ *
11
+ * Setup (cloud):
12
+ * export OLLAMA_API_KEY="your-key"
13
+ *
14
+ * Setup (local):
15
+ * ollama serve # default http://localhost:11434
16
+ *
17
+ * Usage:
18
+ * agent-sh -e ./examples/extensions/ollama.ts
19
+ *
20
+ * # Or add to settings.json:
21
+ * { "extensions": ["./examples/extensions/ollama.ts"] }
22
+ */
23
+ import type { ExtensionContext } from "agent-sh/types";
24
+
25
+ const ECHO_REASONING_PATTERNS: RegExp[] = [/deepseek/i];
26
+
27
+ export default function activate(ctx: ExtensionContext): void {
28
+ const apiKey = process.env.OLLAMA_API_KEY;
29
+ const host = apiKey
30
+ ? "https://ollama.com"
31
+ : (process.env.OLLAMA_HOST ?? "http://localhost:11434").replace(/\/$/, "");
32
+ const id = apiKey ? "ollama-cloud" : "ollama";
33
+
34
+ // OpenAI SDK rejects an empty apiKey; the local daemon ignores the value.
35
+ const sdkKey = apiKey || "no-key";
36
+ const baseURL = `${host}/v1`;
37
+ const headers: Record<string, string> = {};
38
+ if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
39
+
40
+ ctx.bus.emit("provider:register", { id, apiKey: sdkKey, baseURL, models: [] });
41
+
42
+ fetchCatalog(host, headers).then((models) => {
43
+ if (models.length === 0) return;
44
+ ctx.bus.emit("provider:register", {
45
+ id,
46
+ apiKey: sdkKey,
47
+ baseURL,
48
+ defaultModel: models[0]!.id,
49
+ models,
50
+ });
51
+ }).catch(() => { /* leave empty — user supplies via --model */ });
52
+ }
53
+
54
+ async function fetchCatalog(
55
+ host: string,
56
+ headers: Record<string, string>,
57
+ ): Promise<{ id: string; contextWindow?: number; echoReasoning: boolean }[]> {
58
+ const tagsRes = await fetch(`${host}/api/tags`, { headers });
59
+ if (!tagsRes.ok) return [];
60
+ const tagsData = await tagsRes.json() as { models?: { name: string }[] };
61
+ const names = (tagsData.models ?? []).map((m) => m.name);
62
+ if (names.length === 0) return [];
63
+
64
+ const ctxs = await Promise.all(
65
+ names.map((name) => fetchContextLength(host, headers, name).catch(() => undefined)),
66
+ );
67
+ return names.map((name, i) => ({
68
+ id: name,
69
+ contextWindow: ctxs[i],
70
+ echoReasoning: ECHO_REASONING_PATTERNS.some((re) => re.test(name)),
71
+ }));
72
+ }
73
+
74
+ async function fetchContextLength(
75
+ host: string,
76
+ headers: Record<string, string>,
77
+ name: string,
78
+ ): Promise<number | undefined> {
79
+ const res = await fetch(`${host}/api/show`, {
80
+ method: "POST",
81
+ headers: { ...headers, "Content-Type": "application/json" },
82
+ body: JSON.stringify({ name }),
83
+ });
84
+ if (!res.ok) return undefined;
85
+ const data = await res.json() as { model_info?: Record<string, unknown> };
86
+ const info = data.model_info ?? {};
87
+ const arch = info["general.architecture"] as string | undefined;
88
+ if (arch) {
89
+ const ctx = info[`${arch}.context_length`];
90
+ if (typeof ctx === "number") return ctx;
91
+ }
92
+ for (const [k, v] of Object.entries(info)) {
93
+ if (k.endsWith(".context_length") && typeof v === "number") return v;
94
+ }
95
+ return undefined;
96
+ }
@@ -1,27 +1,13 @@
1
1
  /**
2
- * Peer mesh — cross-instance communication for agent-sh.
2
+ * Peer mesh — cross-instance discovery + RPC over Unix sockets.
3
3
  *
4
- * Lets all running ash instances discover each other and communicate.
5
- * Inspired by Ray's @ray.remote: opt-in exposure of named handlers,
6
- * transparent network calls, automatic discovery.
7
- *
8
- * What this extension provides:
9
- * 1. PeerServer — Unix socket server + peer file registry + client
10
- * 2. Standard exposed handlers — terminal read, context, search
11
- * 3. Agent tools — peers, peer_terminal, peer_history, peer_search
12
- * 4. Handler registry API — peer:call, peer:discover, peer:expose
13
- * for other extensions to use
4
+ * Each running ash exposes a small set of named handlers; tools let the
5
+ * agent enumerate peers, read another peer's terminal, drive its keys,
6
+ * send messages, and ask synchronous questions.
14
7
  *
15
8
  * Usage:
16
9
  * ash -e ./examples/extensions/peer-mesh.ts
17
- *
18
- * # Or install permanently
19
10
  * cp examples/extensions/peer-mesh.ts ~/.agent-sh/extensions/
20
- *
21
- * Other extensions can depend on this via the handler registry:
22
- * ctx.define("my:data", () => computeData());
23
- * ctx.call("peer:expose", "my:data");
24
- * const result = await ctx.call("peer:call", peerId, "my:data");
25
11
  */
26
12
  import * as fs from "node:fs";
27
13
  import * as net from "node:net";
@@ -29,8 +15,6 @@ import * as os from "node:os";
29
15
  import * as path from "node:path";
30
16
  import type { ExtensionContext } from "agent-sh/types";
31
17
 
32
- // ── Types ──────────────────────────────────────────────────────
33
-
34
18
  interface PeerInfo {
35
19
  id: string;
36
20
  pid: number;
@@ -39,20 +23,19 @@ interface PeerInfo {
39
23
  startTime: number;
40
24
  }
41
25
 
42
- interface RpcRequest {
43
- method: string;
44
- args: unknown[];
45
- }
46
-
47
- interface RpcResponse {
48
- ok: boolean;
49
- result?: unknown;
50
- error?: string;
51
- }
52
-
53
- // ── Paths ──────────────────────────────────────────────────────
26
+ interface RpcRequest { method: string; args: unknown[]; }
27
+ interface RpcResponse { ok: boolean; result?: unknown; error?: string; }
28
+ interface InboxEntry { from: string; text: string; at: number; }
54
29
 
55
30
  const PEERS_DIR = path.join(os.homedir(), ".agent-sh", "peers");
31
+ const MAX_SEND_BYTES = 2048;
32
+ const DEFAULT_TIMEOUT_MS = 5_000;
33
+ const ASK_TIMEOUT_MS = 120_000;
34
+ const ASK_QUEUE_MAX = 3;
35
+ const SETTLE_MS = 400;
36
+ const IDLE_GUARD_MS = 500;
37
+ const INBOX_MAX = 100;
38
+ const LONG_TIMEOUT_METHODS = new Set<string>(["peer:ask"]);
56
39
 
57
40
  function peerFilePath(id: string): string {
58
41
  return path.join(PEERS_DIR, `${id}.json`);
@@ -62,7 +45,18 @@ function socketPath(pid: number): string {
62
45
  return path.join(os.tmpdir(), `agent-sh-peer-${pid}.sock`);
63
46
  }
64
47
 
65
- // ── PeerServer ─────────────────────────────────────────────────
48
+ // Expand backslash escapes so callers can send Enter / Ctrl-keys via JSON.
49
+ function interpretEscapes(s: string): string {
50
+ return s.replace(/\\(x[0-9a-fA-F]{2}|r|n|t|\\|0)/g, (_, seq: string) => {
51
+ if (seq === "r") return "\r";
52
+ if (seq === "n") return "\n";
53
+ if (seq === "t") return "\t";
54
+ if (seq === "\\") return "\\";
55
+ if (seq === "0") return "\0";
56
+ if (seq.startsWith("x")) return String.fromCharCode(parseInt(seq.slice(1), 16));
57
+ return seq;
58
+ });
59
+ }
66
60
 
67
61
  class PeerServer {
68
62
  private server: net.Server | null = null;
@@ -85,24 +79,13 @@ class PeerServer {
85
79
  };
86
80
  }
87
81
 
88
- // ── Lifecycle ──────────────────────────────────────────────
89
-
90
82
  start(): void {
91
- // Ensure peers directory exists
92
83
  fs.mkdirSync(PEERS_DIR, { recursive: true });
93
-
94
- // Clean up stale socket
95
84
  try { fs.unlinkSync(this.info.socketPath); } catch {}
96
-
97
- // Start Unix socket server
98
85
  this.server = net.createServer((conn) => this.handleConnection(conn));
99
- this.server.on("error", () => {}); // swallow server errors
86
+ this.server.on("error", () => {});
100
87
  this.server.listen(this.info.socketPath);
101
-
102
- // Register peer file
103
88
  fs.writeFileSync(peerFilePath(this.info.id), JSON.stringify(this.info));
104
-
105
- // Cleanup on exit
106
89
  const cleanup = () => this.stop();
107
90
  process.on("exit", cleanup);
108
91
  process.on("SIGTERM", () => { cleanup(); process.exit(0); });
@@ -110,89 +93,68 @@ class PeerServer {
110
93
  }
111
94
 
112
95
  stop(): void {
113
- if (this.server) {
114
- try { this.server.close(); } catch {}
115
- this.server = null;
116
- }
96
+ if (this.server) { try { this.server.close(); } catch {} this.server = null; }
117
97
  try { fs.unlinkSync(this.info.socketPath); } catch {}
118
98
  try { fs.unlinkSync(peerFilePath(this.info.id)); } catch {}
119
99
  }
120
100
 
121
- // ── Expose / discover / call ───────────────────────────────
101
+ expose(name: string): void { this.exposed.add(name); }
122
102
 
123
- expose(name: string): void {
124
- this.exposed.add(name);
103
+ updateCwd(cwd: string): void {
104
+ this.info.cwd = cwd;
105
+ try { fs.writeFileSync(peerFilePath(this.info.id), JSON.stringify(this.info)); } catch {}
125
106
  }
126
107
 
127
108
  discover(): PeerInfo[] {
128
109
  const peers: PeerInfo[] = [];
129
110
  let entries: string[];
130
111
  try { entries = fs.readdirSync(PEERS_DIR); } catch { return []; }
131
-
132
112
  for (const entry of entries) {
133
113
  if (!entry.endsWith(".json")) continue;
134
114
  try {
135
- const raw = fs.readFileSync(path.join(PEERS_DIR, entry), "utf-8");
136
- const info: PeerInfo = JSON.parse(raw);
137
- // Skip self
115
+ const info: PeerInfo = JSON.parse(fs.readFileSync(path.join(PEERS_DIR, entry), "utf-8"));
138
116
  if (info.id === this.info.id) continue;
139
- // Check if process is alive
140
117
  try { process.kill(info.pid, 0); } catch {
141
- // Stale — prune
142
118
  try { fs.unlinkSync(path.join(PEERS_DIR, entry)); } catch {}
143
119
  continue;
144
120
  }
145
121
  peers.push(info);
146
- } catch {
147
- // Malformed file — skip
148
- }
122
+ } catch { /* skip */ }
149
123
  }
150
124
  return peers;
151
125
  }
152
126
 
153
127
  async call(peerId: string, method: string, ...args: unknown[]): Promise<unknown> {
154
- // Find peer socket path
155
- const peers = this.discover();
156
- const peer = peers.find((p) => p.id === peerId);
128
+ const peer = this.discover().find((p) => p.id === peerId);
157
129
  if (!peer) throw new Error(`Peer "${peerId}" not found`);
158
-
159
130
  return this.callSocket(peer.socketPath, method, args);
160
131
  }
161
132
 
162
- // ── Private ────────────────────────────────────────────────
163
-
164
133
  private handleConnection(conn: net.Socket): void {
165
134
  let buffer = "";
166
- conn.on("data", (data) => {
135
+ conn.on("data", async (data) => {
167
136
  buffer += data.toString();
168
137
  const newlineIdx = buffer.indexOf("\n");
169
138
  if (newlineIdx === -1) return;
170
-
171
139
  const line = buffer.slice(0, newlineIdx);
172
140
  buffer = buffer.slice(newlineIdx + 1);
173
-
174
141
  let response: RpcResponse;
175
142
  try {
176
143
  const req: RpcRequest = JSON.parse(line);
177
144
  if (!this.exposed.has(req.method)) {
178
145
  response = { ok: false, error: `Handler "${req.method}" is not exposed` };
179
146
  } else {
180
- const result = this.callHandler(req.method, ...(req.args ?? []));
147
+ const result = await this.callHandler(req.method, ...(req.args ?? []));
181
148
  response = { ok: true, result };
182
149
  }
183
150
  } catch (e) {
184
151
  response = { ok: false, error: e instanceof Error ? e.message : String(e) };
185
152
  }
186
-
187
- try {
188
- conn.write(JSON.stringify(response) + "\n");
189
- } catch {}
153
+ try { conn.write(JSON.stringify(response) + "\n"); } catch {}
190
154
  conn.end();
191
155
  });
192
-
193
156
  conn.on("error", () => {});
194
- // Timeout in case client hangs
195
- conn.setTimeout(5000, () => conn.destroy());
157
+ conn.setTimeout(ASK_TIMEOUT_MS + 5_000, () => conn.destroy());
196
158
  }
197
159
 
198
160
  private callSocket(sockPath: string, method: string, args: unknown[]): Promise<unknown> {
@@ -200,44 +162,33 @@ class PeerServer {
200
162
  const conn = net.createConnection(sockPath);
201
163
  let buffer = "";
202
164
  let settled = false;
203
-
204
- const settle = (fn: () => void) => {
205
- if (settled) return;
206
- settled = true;
207
- fn();
208
- };
209
-
165
+ const settle = (fn: () => void) => { if (!settled) { settled = true; fn(); } };
210
166
  conn.on("connect", () => {
211
167
  const req: RpcRequest = { method, args };
212
168
  conn.write(JSON.stringify(req) + "\n");
213
169
  });
214
-
215
170
  conn.on("data", (data) => {
216
171
  buffer += data.toString();
217
172
  const newlineIdx = buffer.indexOf("\n");
218
173
  if (newlineIdx === -1) return;
219
-
220
- const line = buffer.slice(0, newlineIdx);
221
174
  try {
222
- const resp: RpcResponse = JSON.parse(line);
175
+ const resp: RpcResponse = JSON.parse(buffer.slice(0, newlineIdx));
223
176
  settle(() => resp.ok ? resolve(resp.result) : reject(new Error(resp.error)));
224
177
  } catch (e) {
225
178
  settle(() => reject(e));
226
179
  }
227
180
  conn.end();
228
181
  });
229
-
230
182
  conn.on("error", (e) => settle(() => reject(e)));
231
- conn.setTimeout(5000, () => settle(() => {
232
- reject(new Error("Peer call timed out"));
183
+ const timeout = LONG_TIMEOUT_METHODS.has(method) ? ASK_TIMEOUT_MS : DEFAULT_TIMEOUT_MS;
184
+ conn.setTimeout(timeout, () => settle(() => {
185
+ reject(new Error(`Peer call timed out after ${timeout}ms`));
233
186
  conn.destroy();
234
187
  }));
235
188
  });
236
189
  }
237
190
  }
238
191
 
239
- // ── Extension ──────────────────────────────────────────────────
240
-
241
192
  export default function activate(ctx: ExtensionContext): void {
242
193
  const { bus, contextManager, registerCommand, registerTool, registerInstruction, define } = ctx;
243
194
  const startTime = Date.now();
@@ -245,7 +196,9 @@ export default function activate(ctx: ExtensionContext): void {
245
196
  const server = new PeerServer(ctx.instanceId, contextManager.getCwd(), (...args) => ctx.call(...args));
246
197
  server.start();
247
198
 
248
- // ── Standard handlers (define + expose) ────────────────────
199
+ // Track PTY idle window so peer:terminal-send doesn't stomp on a busy shell.
200
+ let lastPtyOutputTs = startTime;
201
+ bus.on("shell:pty-data", () => { lastPtyOutputTs = Date.now(); });
249
202
 
250
203
  define("peer:info", () => ({
251
204
  id: ctx.instanceId,
@@ -262,69 +215,111 @@ export default function activate(ctx: ExtensionContext): void {
262
215
  });
263
216
  server.expose("peer:terminal-read");
264
217
 
218
+ define("peer:terminal-send", async (keys: string, requireIdleMs?: number, settleMs?: number) => {
219
+ if (typeof keys !== "string" || keys.length === 0) {
220
+ throw new Error("peer:terminal-send requires non-empty keys string");
221
+ }
222
+ if (Buffer.byteLength(keys, "utf-8") > MAX_SEND_BYTES) {
223
+ throw new Error(`keys payload exceeds ${MAX_SEND_BYTES} bytes`);
224
+ }
225
+ const threshold = typeof requireIdleMs === "number" ? requireIdleMs : IDLE_GUARD_MS;
226
+ const idleMs = Date.now() - lastPtyOutputTs;
227
+ if (idleMs < threshold) {
228
+ return { sent: false, reason: "not_idle", idle_ms: idleMs, required_ms: threshold };
229
+ }
230
+ bus.emit("shell:pty-write", { data: interpretEscapes(keys) });
231
+ await new Promise((r) => setTimeout(r, typeof settleMs === "number" ? settleMs : SETTLE_MS));
232
+ const tb = ctx.terminalBuffer;
233
+ return { sent: true, screen: tb ? tb.readScreen({ includeScrollback: false }) : null };
234
+ });
235
+ server.expose("peer:terminal-send");
236
+
265
237
  define("peer:context-recent", (n: number = 15) => contextManager.getRecentSummary(n));
266
238
  server.expose("peer:context-recent");
267
239
 
268
240
  define("peer:context-search", (query: string) => contextManager.search(query));
269
241
  server.expose("peer:context-search");
270
242
 
271
- // ── Inter-peer messaging ───────────────────────────────────
272
-
273
- interface InboxEntry { from: string; text: string; at: number; }
243
+ // ── Inbox + drained turn ──────────────────────────────────────
274
244
  const inbox: InboxEntry[] = [];
275
- const INBOX_MAX = 100;
276
-
277
245
  const pending: InboxEntry[] = [];
246
+ let busy = false;
247
+
248
+ function drainPending(): void {
249
+ if (busy || pending.length === 0) return;
250
+ const batch = pending.splice(0, pending.length);
251
+ const lines = batch.map((e) => `[from peer ${e.from}] ${e.text}`);
252
+ busy = true;
253
+ bus.emit("agent:submit", {
254
+ query: [
255
+ "You received message(s) from other peer(s) in the mesh:",
256
+ "",
257
+ ...lines,
258
+ "",
259
+ "Decide whether to reply (via `peer_send`), act on the request, or note and continue.",
260
+ ].join("\n"),
261
+ });
262
+ }
263
+
264
+ bus.on("agent:processing-done", () => { busy = false; setTimeout(drainPending, 100); });
278
265
 
279
266
  define("peer:message", (from: string, text: string) => {
280
267
  if (typeof from !== "string" || typeof text !== "string") {
281
268
  throw new Error("peer:message requires (from: string, text: string)");
282
269
  }
270
+ if (Buffer.byteLength(text, "utf-8") > MAX_SEND_BYTES) {
271
+ throw new Error(`text payload exceeds ${MAX_SEND_BYTES} bytes`);
272
+ }
283
273
  const entry: InboxEntry = { from, text, at: Date.now() };
284
274
  inbox.push(entry);
285
275
  if (inbox.length > INBOX_MAX) inbox.splice(0, inbox.length - INBOX_MAX);
286
276
  pending.push(entry);
287
- bus.emit("peer:message-received", entry);
288
277
  bus.emit("ui:info", { message: `[peer ${from}] ${text}` });
289
278
  setTimeout(drainPending, 100);
290
279
  return { ok: true };
291
280
  });
292
281
  server.expose("peer:message");
293
282
 
294
- // Drain pending peer messages by injecting a synthetic user turn.
295
- // Only one submission per processing cycle wait for agent:processing-done
296
- // before releasing the next batch.
297
- let busy = false;
298
-
299
- function drainPending(): void {
300
- if (busy || pending.length === 0) return;
301
- const batch = pending.splice(0, pending.length);
302
- const lines = batch.map((e) => `[from peer ${e.from}] ${e.text}`);
303
- const query = [
304
- "You received message(s) from other peer(s) in the mesh:",
305
- "",
306
- ...lines,
307
- "",
308
- "Decide whether to reply (via `peer_send`), act on the request, or note and continue.",
309
- ].join("\n");
310
- busy = true;
311
- bus.emit("agent:submit", { query });
312
- }
283
+ // ── Synchronous Q&A: peer A asks B, blocks until B's next turn answers ──
284
+ interface AskSlot { resolve: (answer: string) => void; reject: (e: Error) => void; from: string; question: string; }
285
+ const askQueue: AskSlot[] = [];
313
286
 
314
- bus.on("agent:processing-done", () => {
315
- busy = false;
316
- setTimeout(drainPending, 100);
287
+ define("peer:ask", (from: string, question: string) => {
288
+ if (typeof from !== "string" || typeof question !== "string") {
289
+ throw new Error("peer:ask requires (from: string, question: string)");
290
+ }
291
+ if (askQueue.length >= ASK_QUEUE_MAX) {
292
+ throw new Error(`peer:ask queue full (max ${ASK_QUEUE_MAX})`);
293
+ }
294
+ if (Buffer.byteLength(question, "utf-8") > MAX_SEND_BYTES) {
295
+ throw new Error(`question payload exceeds ${MAX_SEND_BYTES} bytes`);
296
+ }
297
+ return new Promise<string>((resolve, reject) => {
298
+ const slot: AskSlot = { resolve, reject, from, question };
299
+ askQueue.push(slot);
300
+ const timer = setTimeout(() => {
301
+ const idx = askQueue.indexOf(slot);
302
+ if (idx >= 0) askQueue.splice(idx, 1);
303
+ reject(new Error("peer:ask timed out"));
304
+ }, ASK_TIMEOUT_MS);
305
+ bus.emit("ui:info", { message: `[peer ${from} asks] ${question}` });
306
+ bus.emit("agent:submit", {
307
+ query: [
308
+ `Peer ${from} asks: ${question}`,
309
+ "",
310
+ "Answer with `peer_answer` (your reply will be returned synchronously).",
311
+ ].join("\n"),
312
+ });
313
+ // Once resolved/rejected by peer_answer, clear the timer.
314
+ const origResolve = slot.resolve;
315
+ const origReject = slot.reject;
316
+ slot.resolve = (a) => { clearTimeout(timer); origResolve(a); };
317
+ slot.reject = (e) => { clearTimeout(timer); origReject(e); };
318
+ });
317
319
  });
320
+ server.expose("peer:ask");
318
321
 
319
- // ── Handler registry API (for other extensions) ────────────
320
-
321
- define("peer:discover", () => server.discover());
322
- define("peer:call", (peerId: string, method: string, ...args: unknown[]) =>
323
- server.call(peerId, method, ...args));
324
- define("peer:expose", (name: string) => server.expose(name));
325
-
326
- // ── Agent tools ────────────────────────────────────────────
327
-
322
+ // ── Tools ─────────────────────────────────────────────────────
328
323
  registerTool({
329
324
  name: "peers",
330
325
  description: "List all running agent-sh instances that can be communicated with.",
@@ -332,63 +327,85 @@ export default function activate(ctx: ExtensionContext): void {
332
327
  showOutput: false,
333
328
  getDisplayInfo: () => ({ kind: "search" as const }),
334
329
  formatCall: () => "discovering peers",
335
-
336
330
  async execute() {
337
331
  const peers = server.discover();
338
332
  if (peers.length === 0) {
339
333
  return { content: "No other agent-sh instances found.", exitCode: 0, isError: false };
340
334
  }
341
335
  const lines = peers.map((p) =>
342
- `- id: ${p.id}, pid: ${p.pid}, cwd: ${p.cwd}, uptime: ${Math.round((Date.now() - p.startTime) / 1000)}s`
336
+ `- id: ${p.id}, pid: ${p.pid}, cwd: ${p.cwd}, uptime: ${Math.round((Date.now() - p.startTime) / 1000)}s`,
343
337
  );
344
- return {
345
- content: `Found ${peers.length} peer(s):\n${lines.join("\n")}`,
346
- exitCode: 0,
347
- isError: false,
348
- };
338
+ return { content: `Found ${peers.length} peer(s):\n${lines.join("\n")}`, exitCode: 0, isError: false };
349
339
  },
350
-
351
- formatResult: (_args, result) => ({
352
- summary: result.content.startsWith("No") ? "none found" : result.content.split("\n")[0],
353
- }),
340
+ formatResult: (_a, r) => ({ summary: r.content.startsWith("No") ? "none found" : r.content.split("\n")[0] }),
354
341
  });
355
342
 
356
343
  registerTool({
357
344
  name: "peer_terminal",
358
- description: "Read the terminal screen content of another running agent-sh instance. Shows what is currently visible on their terminal.",
345
+ description: "Read the terminal screen content of another running agent-sh instance.",
359
346
  input_schema: {
360
347
  type: "object",
361
- properties: {
362
- peer_id: { type: "string", description: "The instance ID of the peer (from the peers tool)." },
363
- },
348
+ properties: { peer_id: { type: "string", description: "Instance ID of the peer (from `peers`)." } },
364
349
  required: ["peer_id"],
365
350
  },
366
351
  showOutput: false,
367
352
  getDisplayInfo: () => ({ kind: "read" as const }),
368
- formatCall: (args) => `peer ${args.peer_id}`,
369
-
353
+ formatCall: (a) => `peer ${a.peer_id}`,
370
354
  async execute(args) {
371
355
  try {
372
356
  const screen = await server.call(args.peer_id as string, "peer:terminal-read") as any;
373
357
  const text = screen?.text?.trim() || "(empty screen)";
374
358
  const alt = screen?.altScreen ? " [alternate screen active]" : "";
375
- return {
376
- content: `Terminal content from peer ${args.peer_id}${alt}:\n\n${text}`,
377
- exitCode: 0,
378
- isError: false,
379
- };
359
+ return { content: `Terminal content from peer ${args.peer_id}${alt}:\n\n${text}`, exitCode: 0, isError: false };
380
360
  } catch (e) {
381
- return {
382
- content: `Failed to read peer terminal: ${e instanceof Error ? e.message : String(e)}`,
383
- exitCode: 1,
384
- isError: true,
385
- };
361
+ return { content: `Failed to read peer terminal: ${e instanceof Error ? e.message : String(e)}`, exitCode: 1, isError: true };
386
362
  }
387
363
  },
364
+ formatResult: (_a, r) => ({ summary: r.isError ? "failed" : `${r.content.split("\n").length - 2} lines` }),
365
+ });
388
366
 
389
- formatResult: (_args, result) => ({
390
- summary: result.isError ? "failed" : `${result.content.split("\n").length - 2} lines`,
391
- }),
367
+ registerTool({
368
+ name: "peer_terminal_send",
369
+ description:
370
+ "Type keys into another peer's terminal PTY. Supports backslash escapes " +
371
+ "(`\\r` for Enter, `\\n`, `\\t`, `\\xNN` for raw bytes, `\\\\` for literal backslash). " +
372
+ "Refuses to send if the peer's PTY produced output within the idle threshold (default 500ms).",
373
+ input_schema: {
374
+ type: "object",
375
+ properties: {
376
+ peer_id: { type: "string", description: "Instance ID of the peer." },
377
+ keys: { type: "string", description: "Key sequence to send. Append `\\r` to submit a command." },
378
+ require_idle_ms: { type: "number", description: "Refuse if peer was active within this many ms (default 500)." },
379
+ settle_ms: { type: "number", description: "Wait this long after sending before reading the screen back (default 400)." },
380
+ },
381
+ required: ["peer_id", "keys"],
382
+ },
383
+ showOutput: false,
384
+ getDisplayInfo: () => ({ kind: "write" as const }),
385
+ formatCall: (a) => `peer ${a.peer_id}: ${JSON.stringify(String(a.keys).slice(0, 40))}`,
386
+ async execute(args) {
387
+ try {
388
+ const result = await server.call(
389
+ args.peer_id as string,
390
+ "peer:terminal-send",
391
+ args.keys,
392
+ args.require_idle_ms,
393
+ args.settle_ms,
394
+ ) as { sent: boolean; reason?: string; idle_ms?: number; required_ms?: number; screen?: { text: string } | null };
395
+ if (!result.sent) {
396
+ return {
397
+ content: `Refused to send: peer is busy (${result.reason}; idle ${result.idle_ms}ms / required ${result.required_ms}ms).`,
398
+ exitCode: 1,
399
+ isError: true,
400
+ };
401
+ }
402
+ const screen = result.screen?.text?.trim() ?? "(no screen capture)";
403
+ return { content: `Sent. Screen after settle:\n\n${screen}`, exitCode: 0, isError: false };
404
+ } catch (e) {
405
+ return { content: `Failed to send keys: ${e instanceof Error ? e.message : String(e)}`, exitCode: 1, isError: true };
406
+ }
407
+ },
408
+ formatResult: (_a, r) => ({ summary: r.isError ? "failed" : "sent" }),
392
409
  });
393
410
 
394
411
  registerTool({
@@ -397,32 +414,23 @@ export default function activate(ctx: ExtensionContext): void {
397
414
  input_schema: {
398
415
  type: "object",
399
416
  properties: {
400
- peer_id: { type: "string", description: "The instance ID of the peer." },
401
- count: { type: "number", description: "Number of recent exchanges to return (default: 15)." },
417
+ peer_id: { type: "string" },
418
+ count: { type: "number", description: "Number of recent exchanges (default 15)." },
402
419
  },
403
420
  required: ["peer_id"],
404
421
  },
405
422
  showOutput: false,
406
423
  getDisplayInfo: () => ({ kind: "read" as const }),
407
- formatCall: (args) => `peer ${args.peer_id}`,
408
-
424
+ formatCall: (a) => `peer ${a.peer_id}`,
409
425
  async execute(args) {
410
426
  try {
411
- const n = (args.count as number) || 15;
412
- const summary = await server.call(args.peer_id as string, "peer:context-recent", n) as string;
427
+ const summary = await server.call(args.peer_id as string, "peer:context-recent", (args.count as number) || 15) as string;
413
428
  return { content: summary || "(no history)", exitCode: 0, isError: false };
414
429
  } catch (e) {
415
- return {
416
- content: `Failed to read peer history: ${e instanceof Error ? e.message : String(e)}`,
417
- exitCode: 1,
418
- isError: true,
419
- };
430
+ return { content: `Failed to read peer history: ${e instanceof Error ? e.message : String(e)}`, exitCode: 1, isError: true };
420
431
  }
421
432
  },
422
-
423
- formatResult: (_args, result) => ({
424
- summary: result.isError ? "failed" : `${result.content.split("\n").length} lines`,
425
- }),
433
+ formatResult: (_a, r) => ({ summary: r.isError ? "failed" : `${r.content.split("\n").length} lines` }),
426
434
  });
427
435
 
428
436
  registerTool({
@@ -430,67 +438,43 @@ export default function activate(ctx: ExtensionContext): void {
430
438
  description: "Search another agent-sh instance's shell context by keyword or regex.",
431
439
  input_schema: {
432
440
  type: "object",
433
- properties: {
434
- peer_id: { type: "string", description: "The instance ID of the peer." },
435
- query: { type: "string", description: "Search query (keyword or regex)." },
436
- },
441
+ properties: { peer_id: { type: "string" }, query: { type: "string" } },
437
442
  required: ["peer_id", "query"],
438
443
  },
439
444
  showOutput: false,
440
445
  getDisplayInfo: () => ({ kind: "search" as const }),
441
- formatCall: (args) => `peer ${args.peer_id}: "${args.query}"`,
442
-
446
+ formatCall: (a) => `peer ${a.peer_id}: "${a.query}"`,
443
447
  async execute(args) {
444
448
  try {
445
- const results = await server.call(
446
- args.peer_id as string, "peer:context-search", args.query as string,
447
- ) as string;
449
+ const results = await server.call(args.peer_id as string, "peer:context-search", args.query as string) as string;
448
450
  return { content: results || "(no matches)", exitCode: 0, isError: false };
449
451
  } catch (e) {
450
- return {
451
- content: `Failed to search peer context: ${e instanceof Error ? e.message : String(e)}`,
452
- exitCode: 1,
453
- isError: true,
454
- };
452
+ return { content: `Failed to search peer context: ${e instanceof Error ? e.message : String(e)}`, exitCode: 1, isError: true };
455
453
  }
456
454
  },
457
-
458
- formatResult: (_args, result) => ({
459
- summary: result.isError ? "failed" : `${result.content.split("\n").length} lines`,
460
- }),
455
+ formatResult: (_a, r) => ({ summary: r.isError ? "failed" : `${r.content.split("\n").length} lines` }),
461
456
  });
462
457
 
463
458
  registerTool({
464
459
  name: "peer_send",
465
- description: "Send a text message to another running agent-sh peer. The peer will see it in their UI and on their next turn.",
460
+ description: "Send a text message to another peer. Appears in their UI and is queued for their next turn.",
466
461
  input_schema: {
467
462
  type: "object",
468
- properties: {
469
- peer_id: { type: "string", description: "The instance ID of the peer (from the peers tool)." },
470
- text: { type: "string", description: "Message body." },
471
- },
463
+ properties: { peer_id: { type: "string" }, text: { type: "string" } },
472
464
  required: ["peer_id", "text"],
473
465
  },
474
466
  showOutput: false,
475
467
  getDisplayInfo: () => ({ kind: "write" as const }),
476
- formatCall: (args) => `peer ${args.peer_id}: "${String(args.text).slice(0, 40)}"`,
477
-
468
+ formatCall: (a) => `peer ${a.peer_id}: "${String(a.text).slice(0, 40)}"`,
478
469
  async execute(args) {
479
470
  try {
480
471
  await server.call(args.peer_id as string, "peer:message", ctx.instanceId, args.text as string);
481
472
  return { content: `Sent to peer ${args.peer_id}.`, exitCode: 0, isError: false };
482
473
  } catch (e) {
483
- return {
484
- content: `Failed to send: ${e instanceof Error ? e.message : String(e)}`,
485
- exitCode: 1,
486
- isError: true,
487
- };
474
+ return { content: `Failed to send: ${e instanceof Error ? e.message : String(e)}`, exitCode: 1, isError: true };
488
475
  }
489
476
  },
490
-
491
- formatResult: (_args, result) => ({
492
- summary: result.isError ? "failed" : "sent",
493
- }),
477
+ formatResult: (_a, r) => ({ summary: r.isError ? "failed" : "sent" }),
494
478
  });
495
479
 
496
480
  registerTool({
@@ -498,41 +482,76 @@ export default function activate(ctx: ExtensionContext): void {
498
482
  description: "Read recent messages received from other peers via peer_send.",
499
483
  input_schema: {
500
484
  type: "object",
501
- properties: {
502
- count: { type: "number", description: "Number of recent messages to return (default: 20)." },
503
- },
485
+ properties: { count: { type: "number", description: "Max messages to return (default 20)." } },
504
486
  required: [],
505
487
  },
506
488
  showOutput: false,
507
489
  getDisplayInfo: () => ({ kind: "read" as const }),
508
490
  formatCall: () => "reading inbox",
509
-
510
491
  async execute(args) {
511
- const n = (args.count as number) || 20;
512
- const recent = inbox.slice(-n);
513
- if (recent.length === 0) {
514
- return { content: "(inbox empty)", exitCode: 0, isError: false };
515
- }
516
- const lines = recent.map((e) => {
517
- const ago = Math.round((Date.now() - e.at) / 1000);
518
- return `[${ago}s ago] ${e.from}: ${e.text}`;
519
- });
492
+ const recent = inbox.slice(-((args.count as number) || 20));
493
+ if (recent.length === 0) return { content: "(inbox empty)", exitCode: 0, isError: false };
494
+ const lines = recent.map((e) => `[${Math.round((Date.now() - e.at) / 1000)}s ago] ${e.from}: ${e.text}`);
520
495
  return { content: lines.join("\n"), exitCode: 0, isError: false };
521
496
  },
497
+ formatResult: (_a, r) => ({ summary: r.content === "(inbox empty)" ? "empty" : `${r.content.split("\n").length} msg` }),
498
+ });
522
499
 
523
- formatResult: (_args, result) => ({
524
- summary: result.content === "(inbox empty)" ? "empty" : `${result.content.split("\n").length} msg`,
525
- }),
500
+ registerTool({
501
+ name: "peer_ask",
502
+ description:
503
+ "Ask another peer a question and wait synchronously for their answer. " +
504
+ "Blocks for up to 2 minutes; the peer responds via peer_answer.",
505
+ input_schema: {
506
+ type: "object",
507
+ properties: {
508
+ peer_id: { type: "string" },
509
+ question: { type: "string" },
510
+ },
511
+ required: ["peer_id", "question"],
512
+ },
513
+ showOutput: false,
514
+ getDisplayInfo: () => ({ kind: "search" as const }),
515
+ formatCall: (a) => `peer ${a.peer_id}: "${String(a.question).slice(0, 40)}"`,
516
+ async execute(args) {
517
+ try {
518
+ const answer = await server.call(args.peer_id as string, "peer:ask", ctx.instanceId, args.question as string) as string;
519
+ return { content: `Answer from peer ${args.peer_id}:\n\n${answer}`, exitCode: 0, isError: false };
520
+ } catch (e) {
521
+ return { content: `Ask failed: ${e instanceof Error ? e.message : String(e)}`, exitCode: 1, isError: true };
522
+ }
523
+ },
524
+ formatResult: (_a, r) => ({ summary: r.isError ? "failed" : "answered" }),
526
525
  });
527
526
 
528
- // ── Slash command ──────────────────────────────────────────
527
+ registerTool({
528
+ name: "peer_answer",
529
+ description:
530
+ "Resolve the oldest pending peer:ask question with this answer. " +
531
+ "Use only when responding to a peer-asked question received this turn.",
532
+ input_schema: {
533
+ type: "object",
534
+ properties: { answer: { type: "string" } },
535
+ required: ["answer"],
536
+ },
537
+ showOutput: false,
538
+ getDisplayInfo: () => ({ kind: "write" as const }),
539
+ formatCall: (a) => `"${String(a.answer).slice(0, 40)}"`,
540
+ async execute(args) {
541
+ const slot = askQueue.shift();
542
+ if (!slot) {
543
+ return { content: "No pending peer:ask question to answer.", exitCode: 1, isError: true };
544
+ }
545
+ slot.resolve(args.answer as string);
546
+ return { content: `Answered peer ${slot.from}.`, exitCode: 0, isError: false };
547
+ },
548
+ formatResult: (_a, r) => ({ summary: r.isError ? "no pending" : "answered" }),
549
+ });
529
550
 
551
+ // ── Slash command + system prompt + cwd sync ────────────────
530
552
  registerCommand("peers", "List running agent-sh peer instances", () => {
531
553
  const peers = server.discover();
532
- if (peers.length === 0) {
533
- bus.emit("ui:info", { message: "No peers found." });
534
- return;
535
- }
554
+ if (peers.length === 0) { bus.emit("ui:info", { message: "No peers found." }); return; }
536
555
  const lines = peers.map((p) => {
537
556
  const uptime = Math.round((Date.now() - p.startTime) / 1000);
538
557
  return ` ${p.id} pid=${p.pid} cwd=${p.cwd} ${uptime}s`;
@@ -540,26 +559,19 @@ export default function activate(ctx: ExtensionContext): void {
540
559
  bus.emit("ui:info", { message: `Peers:\n${lines.join("\n")}` });
541
560
  });
542
561
 
543
- // ── System prompt instruction ──────────────────────────────
544
-
545
562
  registerInstruction("Peer Mesh", [
546
563
  "You have access to a peer mesh — other running agent-sh instances on this machine.",
547
- "Use the `peers` tool to discover them, then:",
564
+ "Use `peers` to discover them, then:",
548
565
  "- `peer_terminal` to see what's on another terminal's screen",
549
- "- `peer_history` to see what commands they ran recently",
550
- "- `peer_search` to search their shell context by keyword",
551
- "- `peer_send` to deliver a text message to another peer (appears in their UI)",
552
- "- `peer_inbox` to read messages other peers have sent you",
566
+ "- `peer_terminal_send` to type keys into their PTY (use `\\r` for Enter)",
567
+ "- `peer_history` to see recent commands they ran",
568
+ "- `peer_search` to search their shell context",
569
+ "- `peer_send` to deliver a one-way message",
570
+ "- `peer_inbox` to read messages others sent you",
571
+ "- `peer_ask` to ask a question and wait for their answer",
572
+ "- `peer_answer` to respond when another peer has asked you a question this turn",
553
573
  "When the user references 'the other terminal' or 'my other shell', use these tools.",
554
574
  ].join("\n"));
555
575
 
556
- // ── Update CWD in peer file on directory change ────────────
557
-
558
- bus.on("shell:cwd-change", ({ cwd }) => {
559
- try {
560
- const info: PeerInfo = JSON.parse(fs.readFileSync(peerFilePath(ctx.instanceId), "utf-8"));
561
- info.cwd = cwd;
562
- fs.writeFileSync(peerFilePath(ctx.instanceId), JSON.stringify(info));
563
- } catch {}
564
- });
576
+ bus.on("shell:cwd-change", ({ cwd }) => server.updateCwd(cwd));
565
577
  }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * rtk-proxy — transparently rewrites bash commands to `rtk <command>`
3
+ * so the LLM sees rtk's compressed output (60-90% token reduction on
4
+ * common dev commands: git, cargo, npm, jest, pytest, ls, grep, …).
5
+ *
6
+ * Demonstrates: `ctx.advise("tool:execute", …)` wrapping + line-buffered
7
+ * stream scrub.
8
+ *
9
+ * Compound commands like `cd X && pytest` rewrite the last segment only.
10
+ * Pipes, subshells, and redirects are skipped (unsafe to wrap).
11
+ *
12
+ * Requires the `rtk` binary on PATH (https://github.com/rtk-ai/rtk).
13
+ *
14
+ * Settings (~/.agent-sh/settings.json):
15
+ * { "rtk-proxy": { "enabled": true, "ultraCompact": false,
16
+ * "extraPrefixes": [], "excludePrefixes": [] } }
17
+ *
18
+ * Usage:
19
+ * ash -e ./examples/extensions/rtk-proxy.ts
20
+ * cp examples/extensions/rtk-proxy.ts ~/.agent-sh/extensions/
21
+ */
22
+ import { execSync } from "node:child_process";
23
+ import type { ExtensionContext } from "agent-sh/types";
24
+
25
+ const DEFAULT_PREFIXES = new Set([
26
+ "git", "gh",
27
+ "ls", "tree", "find", "grep", "rg", "cat",
28
+ "cargo", "npm", "pnpm", "yarn",
29
+ "jest", "vitest", "pytest", "playwright",
30
+ "go", "ruff", "tsc", "eslint", "prettier", "biome",
31
+ "docker", "kubectl",
32
+ "aws",
33
+ "pip", "bundle", "rake", "rspec", "rubocop",
34
+ "golangci-lint", "next",
35
+ "prisma",
36
+ ]);
37
+
38
+ // Pipes, subshells, redirections — unsafe to wrap. Compound operators
39
+ // (&&, ||, ;) are handled by splitting and rewriting only the last segment.
40
+ const UNSAFE_SEGMENT_RE = /[|`()$><]/;
41
+
42
+ function firstToken(cmd: string): string {
43
+ const m = cmd.trimStart().match(/^(\S+)/);
44
+ return m ? m[1] : "";
45
+ }
46
+
47
+ // Caveat: textual split, no quoting awareness. A literal `&&` inside a
48
+ // quoted argument will split there. Acceptable today because no current
49
+ // prefix-token command takes args containing `&&`/`||`/`;`. If that
50
+ // changes, switch to a proper shell tokenizer.
51
+ function splitLastSegment(cmd: string): [string, string, string] | null {
52
+ const match = cmd.match(/^(.*)(&&|\|\||;)\s*(\S.*)$/s);
53
+ if (!match) return null;
54
+ return [match[1].trimEnd(), match[2], match[3]];
55
+ }
56
+
57
+ function rewriteForRtk(cmd: string, prefixes: Set<string>, flag: string): string | null {
58
+ const tok = firstToken(cmd);
59
+ if (!tok || tok === "rtk") return null;
60
+ // Escape hatch: `command foo` forces raw passthrough.
61
+ if (tok === "command") return null;
62
+
63
+ const parts = splitLastSegment(cmd);
64
+ if (parts) {
65
+ const [prefix, sep, lastSeg] = parts;
66
+ if (UNSAFE_SEGMENT_RE.test(lastSeg)) return null;
67
+ if (!prefixes.has(firstToken(lastSeg))) return null;
68
+ return `${prefix} ${sep} RTK_TELEMETRY_DISABLED=1 rtk ${flag}${lastSeg}`;
69
+ }
70
+
71
+ if (UNSAFE_SEGMENT_RE.test(cmd)) return null;
72
+ if (!prefixes.has(tok)) return null;
73
+ return `RTK_TELEMETRY_DISABLED=1 rtk ${flag}${cmd}`;
74
+ }
75
+
76
+ export default function activate(ctx: ExtensionContext) {
77
+ const config = ctx.getExtensionSettings("rtk-proxy", {
78
+ enabled: true,
79
+ ultraCompact: false,
80
+ extraPrefixes: [] as string[],
81
+ excludePrefixes: [] as string[],
82
+ });
83
+ if (!config.enabled) return;
84
+
85
+ try {
86
+ execSync("command -v rtk", { stdio: "ignore" });
87
+ } catch {
88
+ ctx.bus.emit("ui:info", {
89
+ message: "rtk-proxy: `rtk` binary not on PATH — extension inactive.",
90
+ });
91
+ return;
92
+ }
93
+
94
+ const prefixes = new Set([...DEFAULT_PREFIXES, ...config.extraPrefixes]);
95
+ for (const p of config.excludePrefixes) prefixes.delete(p);
96
+ const flag = config.ultraCompact ? "--ultra-compact " : "";
97
+
98
+ ctx.registerInstruction("rtk-proxy",
99
+ "The rtk-proxy extension transparently rewrites bash commands like " +
100
+ "`git status`, `cargo test`, `pytest` to their rtk-compressed equivalents " +
101
+ "before execution. Output will be condensed (errors/failures first, " +
102
+ "boilerplate stripped). For raw unfiltered output, prefix with `command ` " +
103
+ "(e.g. `command git log`) or pipe (`git log | cat`) — both skip the rewrite.",
104
+ );
105
+
106
+ // rtk prints a nag line when it sees ~/.claude/ but no hook. We're doing
107
+ // the rewrite ourselves, so strip the advisory from streamed + final output.
108
+ const NAG_RE = /^\[(?:rtk|warn)\][^\n]*No hook installed[^\n]*\n?/gm;
109
+ const scrub = (s: string) => s.replace(NAG_RE, "");
110
+
111
+ ctx.advise("tool:execute", async (next, toolCtx) => {
112
+ if (toolCtx.name !== "bash") return next(toolCtx);
113
+ const command = toolCtx.args?.command;
114
+ if (typeof command !== "string") return next(toolCtx);
115
+
116
+ const rewritten = rewriteForRtk(command, prefixes, flag);
117
+ if (rewritten === null) return next(toolCtx);
118
+
119
+ toolCtx.args = { ...toolCtx.args, command: rewritten };
120
+
121
+ // Line-buffer the stream so the nag-line scrub works across chunks.
122
+ const origOnChunk = toolCtx.onChunk;
123
+ if (origOnChunk) {
124
+ let buf = "";
125
+ toolCtx.onChunk = (chunk: string) => {
126
+ buf += chunk;
127
+ const lastNl = buf.lastIndexOf("\n");
128
+ if (lastNl !== -1) {
129
+ origOnChunk(scrub(buf.slice(0, lastNl + 1)));
130
+ buf = buf.slice(lastNl + 1);
131
+ }
132
+ };
133
+ const result = await next(toolCtx);
134
+ if (buf) origOnChunk(scrub(buf));
135
+ return { ...result, content: scrub(result.content) };
136
+ }
137
+ return next(toolCtx);
138
+ });
139
+
140
+ ctx.bus.emit("ui:info", {
141
+ message: `rtk-proxy active (${prefixes.size} command prefixes).`,
142
+ });
143
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sh",
3
- "version": "0.12.10",
3
+ "version": "0.12.11",
4
4
  "description": "A shell-first terminal where AI is one keystroke away",
5
5
  "type": "module",
6
6
  "main": "dist/core.js",
@@ -1,87 +0,0 @@
1
- /**
2
- * OpenRouter provider extension.
3
- *
4
- * Registers OpenRouter as a provider and fetches its full model catalog
5
- * at startup. Models appear in /model autocomplete as "model [openrouter]"
6
- * and are available for cycling with Shift+Tab.
7
- *
8
- * Model capabilities (reasoning, context window) are read from the
9
- * OpenRouter API response — no hardcoded model lists.
10
- *
11
- * Setup:
12
- * export OPENROUTER_API_KEY="your-key"
13
- *
14
- * Usage:
15
- * agent-sh -e ./examples/extensions/openrouter.ts
16
- *
17
- * # Or add to settings.json:
18
- * { "extensions": ["./examples/extensions/openrouter.ts"] }
19
- */
20
- import type { ExtensionContext } from "agent-sh/types";
21
-
22
- const BASE_URL = "https://openrouter.ai/api/v1";
23
- const API_KEY = process.env.OPENROUTER_API_KEY ?? "";
24
-
25
- /** Curated default models — used immediately while the full catalog loads. */
26
- const DEFAULT_MODELS = [
27
- "anthropic/claude-sonnet-4",
28
- "google/gemini-2.5-pro-preview",
29
- "openai/gpt-4.1",
30
- "deepseek/deepseek-r1",
31
- "meta-llama/llama-4-maverick",
32
- ];
33
-
34
- interface OpenRouterModel {
35
- id: string;
36
- name: string;
37
- context_length?: number;
38
- supported_parameters?: string[];
39
- pricing?: { prompt: string; completion: string };
40
- }
41
-
42
- export default function activate({ bus }: ExtensionContext): void {
43
- if (!API_KEY) {
44
- bus.emit("ui:error", {
45
- message: "OpenRouter extension: OPENROUTER_API_KEY not set. Skipping.",
46
- });
47
- return;
48
- }
49
-
50
- // Register provider immediately with curated defaults
51
- bus.emit("provider:register", {
52
- id: "openrouter",
53
- apiKey: API_KEY,
54
- baseURL: BASE_URL,
55
- defaultModel: DEFAULT_MODELS[0],
56
- models: DEFAULT_MODELS,
57
- });
58
-
59
- // Fetch full model catalog in background, re-register with capabilities
60
- fetchModels().then((models) => {
61
- if (models.length > 0) {
62
- bus.emit("provider:register", {
63
- id: "openrouter",
64
- apiKey: API_KEY,
65
- baseURL: BASE_URL,
66
- defaultModel: DEFAULT_MODELS[0],
67
- supportsReasoningEffort: true,
68
- models: models.map((m) => ({
69
- id: m.id,
70
- reasoning: m.supported_parameters?.includes("reasoning") ?? false,
71
- contextWindow: m.context_length,
72
- })),
73
- });
74
- }
75
- }).catch(() => {
76
- // Silently fall back to curated defaults
77
- });
78
- }
79
-
80
- async function fetchModels(): Promise<OpenRouterModel[]> {
81
- const res = await fetch(`${BASE_URL}/models`, {
82
- headers: { Authorization: `Bearer ${API_KEY}` },
83
- });
84
- if (!res.ok) return [];
85
- const data = await res.json();
86
- return (data.data ?? []) as OpenRouterModel[];
87
- }