agent-sh 0.14.11 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +38 -42
  2. package/dist/agent/agent-loop.d.ts +9 -17
  3. package/dist/agent/agent-loop.js +104 -136
  4. package/dist/agent/events.d.ts +8 -11
  5. package/dist/agent/host-types.d.ts +17 -11
  6. package/dist/agent/index.d.ts +1 -1
  7. package/dist/agent/index.js +38 -22
  8. package/dist/agent/providers/deepseek.js +9 -1
  9. package/dist/agent/session-store.js +1 -1
  10. package/dist/agent/system-prompt.d.ts +7 -3
  11. package/dist/agent/system-prompt.js +11 -14
  12. package/dist/agent/tool-protocol.js +0 -7
  13. package/dist/cli/args.js +2 -1
  14. package/dist/cli/install.d.ts +1 -0
  15. package/dist/cli/install.js +29 -1
  16. package/dist/cli/subcommands.js +1 -0
  17. package/dist/core/event-bus.js +0 -2
  18. package/dist/core/extension-loader.js +3 -1
  19. package/dist/core/index.d.ts +1 -1
  20. package/dist/core/index.js +3 -2
  21. package/dist/extensions/slash-commands/index.js +16 -11
  22. package/dist/shell/index.js +9 -0
  23. package/dist/shell/shell-context.d.ts +2 -2
  24. package/dist/shell/shell-context.js +26 -11
  25. package/dist/shell/tui-renderer.js +0 -1
  26. package/dist/utils/diff-renderer.js +2 -9
  27. package/dist/utils/handler-registry.d.ts +1 -6
  28. package/dist/utils/handler-registry.js +1 -6
  29. package/dist/utils/line-editor.js +0 -2
  30. package/dist/utils/palette.js +4 -4
  31. package/dist/utils/terminal-buffer.d.ts +2 -0
  32. package/dist/utils/terminal-buffer.js +4 -0
  33. package/examples/extensions/ash-acp-bridge/src/index.ts +11 -7
  34. package/examples/extensions/ash-scheme/index.ts +104 -74
  35. package/examples/extensions/ashi/EXTENDING.md +2 -0
  36. package/examples/extensions/ashi/README.md +17 -1
  37. package/examples/extensions/ashi/docs/ui-surface-protocol.md +163 -0
  38. package/examples/extensions/ashi/package.json +9 -1
  39. package/examples/extensions/ashi/src/capture.ts +45 -7
  40. package/examples/extensions/ashi/src/chat/assistant.ts +23 -43
  41. package/examples/extensions/ashi/src/chat/lines.ts +20 -1
  42. package/examples/extensions/ashi/src/cli.ts +25 -3
  43. package/examples/extensions/ashi/src/clipboard-image.ts +1 -1
  44. package/examples/extensions/ashi/src/dialogs.ts +67 -0
  45. package/examples/extensions/ashi/src/display-config.ts +7 -0
  46. package/examples/extensions/ashi/src/docks.ts +31 -0
  47. package/examples/extensions/ashi/src/events.ts +16 -0
  48. package/examples/extensions/ashi/src/frontend.ts +134 -27
  49. package/examples/extensions/ashi/src/hooks.ts +6 -12
  50. package/examples/extensions/ashi/src/input-prompt.ts +64 -0
  51. package/examples/extensions/ashi/src/renderers/pi-tui/index.ts +7 -3
  52. package/examples/extensions/ashi/src/renderers/pi-tui/nodes.ts +67 -10
  53. package/examples/extensions/ashi/src/renderers/pi-tui/schema-mount.ts +11 -1
  54. package/examples/extensions/ashi/src/schema.ts +3 -0
  55. package/examples/extensions/ashi/src/session-commands.ts +2 -1
  56. package/examples/extensions/ashi/src/status-footer.ts +21 -3
  57. package/examples/extensions/ashi/src/ui.ts +88 -0
  58. package/examples/extensions/ashi-ink/README.md +2 -0
  59. package/examples/extensions/ashi-scheme-render.ts +8 -2
  60. package/examples/extensions/ashi-ui-demo.ts +63 -0
  61. package/examples/extensions/latex-images.ts +57 -9
  62. package/examples/extensions/overlay-agent.ts +5 -5
  63. package/examples/extensions/pi-bridge/index.ts +7 -12
  64. package/package.json +1 -1
@@ -56,19 +56,16 @@ export interface ProviderRegistration {
56
56
  /** Local daemons etc. — `auth list/login` shows "no auth required". */
57
57
  noAuth?: boolean;
58
58
  }
59
- /** A model entry in the cycling list, optionally tied to a provider. */
60
- export interface AgentMode {
61
- model: string;
62
- /** Provider id — when cycling changes provider, LlmClient is reconfigured. */
63
- provider?: string;
64
- /** Provider-specific config for reconfiguring LlmClient on switch. */
65
- providerConfig?: {
66
- apiKey: string;
67
- baseURL?: string;
68
- };
59
+ /** A selectable (provider, model) target the frontend lists and switches.
60
+ * Serializable identity + capabilities only; the secret + closures needed to
61
+ * invoke it live in ModelEndpoint, so this can safely cross to frontends and
62
+ * out-of-process bridges. */
63
+ export interface Model {
64
+ id: string;
65
+ provider: string;
69
66
  /** Context window size in tokens (for usage display). */
70
67
  contextWindow?: number;
71
- /** Max output tokens for this mode. */
68
+ /** Max output tokens. */
72
69
  maxTokens?: number;
73
70
  /** Model supports reasoning/thinking tokens. */
74
71
  reasoning?: boolean;
@@ -79,7 +76,15 @@ export interface AgentMode {
79
76
  echoReasoning?: boolean;
80
77
  /** Input modalities the model supports. Defaults to ["text"]. */
81
78
  modalities?: ("text" | "image")[];
79
+ }
80
+ /** Credentials + provider-shape transforms for invoking a Model, resolved by
81
+ * (provider, id). Internal: holds a secret (apiKey) and non-serializable
82
+ * closures, so it must never ride a bus event. */
83
+ export interface ModelEndpoint {
84
+ apiKey: string;
85
+ baseURL?: string;
82
86
  buildReasoningParams?: (level: string) => Record<string, unknown>;
87
+ extractCachedTokens?: (usage: Record<string, unknown>) => number | undefined;
83
88
  }
84
89
  /**
85
90
  * Capabilities the agent host adds on top of CoreContext. Only available
@@ -94,6 +99,7 @@ export interface AgentSurface {
94
99
  unregister: (id: string) => void;
95
100
  configure: (id: string, opts: {
96
101
  reasoningParams?: (level: string, model?: string) => Record<string, unknown>;
102
+ cacheTokens?: (usage: Record<string, unknown>) => number | undefined;
97
103
  }) => void;
98
104
  };
99
105
  registerTool: (tool: ToolDefinition) => void;
@@ -11,5 +11,5 @@ export { AgentLoop } from "./agent-loop.js";
11
11
  export { ToolRegistry } from "./tool-registry.js";
12
12
  export { runSubagent, type SubagentOptions } from "./subagent.js";
13
13
  /** Built-in providers register unconditionally so `auth list` can
14
- * enumerate them; buildModes() skips entries without an apiKey. */
14
+ * enumerate them; buildModels() skips entries without an apiKey. */
15
15
  export declare function activateAgent(ctx: ExtensionContext): void;
@@ -43,6 +43,10 @@ function defaultReasoningBuilder(level) {
43
43
  return {};
44
44
  return { reasoning_effort: level === "xhigh" ? "high" : level };
45
45
  }
46
+ function defaultCacheTokens(usage) {
47
+ const details = usage.prompt_tokens_details;
48
+ return typeof details?.cached_tokens === "number" ? details.cached_tokens : undefined;
49
+ }
46
50
  function mergeCaps(settingsCaps, payloadCaps, modelIds) {
47
51
  if (!settingsCaps)
48
52
  return payloadCaps.size > 0 ? payloadCaps : undefined;
@@ -91,11 +95,12 @@ export default function agentBackend(ctx) {
91
95
  settingsProviders.set(name, p);
92
96
  }
93
97
  const providerHooks = new Map();
94
- // Bakes model id so AgentMode.buildReasoningParams keeps its (level) signature.
98
+ // Bakes model id so ModelEndpoint.buildReasoningParams keeps its (level) signature.
95
99
  const bindReasoning = (shapeId, model) => {
96
100
  const hook = providerHooks.get(shapeId)?.reasoningParams;
97
101
  return hook ? (level) => hook(level, model) : defaultReasoningBuilder;
98
102
  };
103
+ const bindCacheTokens = (shapeId) => providerHooks.get(shapeId)?.cacheTokens ?? defaultCacheTokens;
99
104
  const agentSurface = {
100
105
  llm: createLlmFacade({ list: ctx.list, call: ctx.call }),
101
106
  providers: {
@@ -280,33 +285,43 @@ export default function agentBackend(ctx) {
280
285
  }
281
286
  return out;
282
287
  };
283
- const buildModes = () => {
288
+ const buildModels = () => {
284
289
  const out = [];
285
290
  for (const [id, p] of resolvedProviders) {
286
291
  if (!p.apiKey)
287
292
  continue;
288
293
  if (!usableProvider(p))
289
294
  continue;
290
- const shapeId = p.reasoningShape ?? id;
291
295
  for (const model of p.models) {
292
296
  const mc = p.modelCapabilities?.get(model);
293
297
  out.push({
294
- model,
298
+ id: model,
295
299
  provider: id,
296
- providerConfig: { apiKey: p.apiKey, baseURL: p.baseURL },
297
300
  contextWindow: mc?.contextWindow ?? p.contextWindow,
298
301
  maxTokens: mc?.maxTokens ?? (mc?.contextWindow ? Math.min(Math.floor(mc.contextWindow * 0.4), 65536) : undefined),
299
302
  reasoning: mc?.reasoning,
300
303
  supportsReasoningEffort: p.supportsReasoningEffort,
301
304
  echoReasoning: mc?.echoReasoning,
302
305
  modalities: mc?.modalities,
303
- buildReasoningParams: bindReasoning(shapeId, model),
304
306
  });
305
307
  }
306
308
  }
307
309
  return out;
308
310
  };
309
- ctx.define("agent:get-modes", () => buildModes());
311
+ const resolveEndpoint = (providerId, modelId) => {
312
+ const p = resolvedProviders.get(providerId);
313
+ if (!p?.apiKey)
314
+ return undefined;
315
+ const shapeId = p.reasoningShape ?? providerId;
316
+ return {
317
+ apiKey: p.apiKey,
318
+ baseURL: p.baseURL,
319
+ buildReasoningParams: bindReasoning(shapeId, modelId),
320
+ extractCachedTokens: bindCacheTokens(shapeId),
321
+ };
322
+ };
323
+ ctx.define("agent:get-models", () => buildModels());
324
+ ctx.define("agent:resolve-endpoint", ({ provider, id }) => resolveEndpoint(provider, id));
310
325
  // Reconfigured at core:extensions-loaded; start() gates on `resolved`.
311
326
  const llmClient = new LlmClient({ apiKey: "not-configured", model: "not-configured" });
312
327
  ctx.define("llm:get-client", () => llmClient);
@@ -329,10 +344,10 @@ export default function agentBackend(ctx) {
329
344
  resolvedProviders = computeResolvedProviders();
330
345
  if (!resolved)
331
346
  return;
332
- bus.emit("agent:modes-changed", {});
347
+ bus.emit("agent:models-changed", {});
333
348
  if (!ashActive)
334
349
  return;
335
- if (buildModes().some((m) => m.model === llmClient.model))
350
+ if (buildModels().some((m) => m.id === llmClient.model))
336
351
  return;
337
352
  const pendingProvider = getSettings().defaultProvider;
338
353
  if (!pendingProvider)
@@ -342,13 +357,15 @@ export default function agentBackend(ctx) {
342
357
  return;
343
358
  const pendingModel = persistedModelFor(pendingProvider);
344
359
  if (pendingModel && p.models.includes(pendingModel) && llmClient.model !== pendingModel) {
345
- bus.emit("config:switch-model", { model: pendingModel });
360
+ bus.emit("config:switch-model", { id: pendingModel, provider: pendingProvider });
346
361
  }
347
362
  });
348
- bus.on("provider:configure", ({ id, reasoningParams }) => {
363
+ bus.on("provider:configure", ({ id, reasoningParams, cacheTokens }) => {
349
364
  const prev = providerHooks.get(id) ?? {};
350
365
  if (reasoningParams !== undefined)
351
366
  prev.reasoningParams = reasoningParams;
367
+ if (cacheTokens !== undefined)
368
+ prev.cacheTokens = cacheTokens;
352
369
  providerHooks.set(id, prev);
353
370
  });
354
371
  bus.on("core:extensions-loaded", ({ names }) => {
@@ -388,15 +405,14 @@ export default function agentBackend(ctx) {
388
405
  // No provider → don't register ash; let another backend own activation.
389
406
  if (!effectiveApiKey || !effectiveModel)
390
407
  return;
391
- const foundInModes = buildModes().find((m) => m.model === effectiveModel && (!activeProvider || m.provider === activeProvider.id));
408
+ const foundModel = buildModels().find((m) => m.id === effectiveModel && (!activeProvider || m.provider === activeProvider.id));
392
409
  // Stub when openrouter's async catalog hasn't returned yet; reconciled
393
410
  // later via agent:providers:changed → config:switch-model.
394
- const initialMode = foundInModes ?? (activeProvider ? {
395
- model: effectiveModel,
396
- provider: activeProvider.id,
397
- providerConfig: { apiKey: effectiveApiKey, baseURL: effectiveBaseURL },
398
- supportsReasoningEffort: activeProvider.supportsReasoningEffort,
399
- } : { model: effectiveModel });
411
+ const initialModel = foundModel ?? {
412
+ id: effectiveModel,
413
+ provider: activeProvider?.id ?? providerName ?? "custom",
414
+ supportsReasoningEffort: activeProvider?.supportsReasoningEffort,
415
+ };
400
416
  llmClient.reconfigure({ apiKey: effectiveApiKey, baseURL: effectiveBaseURL, model: effectiveModel });
401
417
  resolved = true;
402
418
  bus.emit("agent:register-backend", {
@@ -413,7 +429,7 @@ export default function agentBackend(ctx) {
413
429
  bus,
414
430
  llmClient,
415
431
  handlers: { define: ctx.define, advise: ctx.advise, call: ctx.call, list: ctx.list },
416
- initialMode,
432
+ initialModel,
417
433
  compositor: ctx.shell?.compositor,
418
434
  instanceId: ctx.instanceId,
419
435
  });
@@ -505,8 +521,8 @@ export default function agentBackend(ctx) {
505
521
  return;
506
522
  }
507
523
  llmClient.reconfigure({ apiKey: p.apiKey, baseURL: p.baseURL, model: switchModel });
508
- bus.emit("agent:modes-changed", {});
509
- bus.emit("config:switch-model", { model: switchModel });
524
+ bus.emit("agent:models-changed", {});
525
+ bus.emit("config:switch-model", { id: switchModel, provider: name });
510
526
  bus.emit("ui:info", { message: `Switched to ${name} (${switchModel})` });
511
527
  });
512
528
  bus.onPipe("banner:collect", (e) => {
@@ -526,7 +542,7 @@ export { AgentLoop } from "./agent-loop.js";
526
542
  export { ToolRegistry } from "./tool-registry.js";
527
543
  export { runSubagent } from "./subagent.js";
528
544
  /** Built-in providers register unconditionally so `auth list` can
529
- * enumerate them; buildModes() skips entries without an apiKey. */
545
+ * enumerate them; buildModels() skips entries without an apiKey. */
530
546
  export function activateAgent(ctx) {
531
547
  agentBackend(ctx);
532
548
  const agentCtx = ctx;
@@ -10,7 +10,15 @@ function buildReasoningParams(level, _model) {
10
10
  : { thinking: { type: "enabled" }, reasoning_effort: level };
11
11
  }
12
12
  export default function activate(ctx) {
13
- ctx.agent.providers.configure("deepseek", { reasoningParams: buildReasoningParams });
13
+ ctx.agent.providers.configure("deepseek", {
14
+ reasoningParams: buildReasoningParams,
15
+ // Native DeepSeek reports caching as flat hit/miss counts, not the
16
+ // OpenAI-standard prompt_tokens_details.cached_tokens the default reads.
17
+ cacheTokens: (u) => {
18
+ const hit = u.prompt_cache_hit_tokens;
19
+ return typeof hit === "number" ? hit : undefined;
20
+ },
21
+ });
14
22
  ctx.agent.providers.register({
15
23
  id: "deepseek",
16
24
  apiKey: resolveApiKey("deepseek").key ?? undefined,
@@ -237,7 +237,7 @@ export class SessionStore {
237
237
  for (const e of this.entries.values()) {
238
238
  if (e.type === "message" && e.message.role === "user") {
239
239
  const raw = typeof e.message.content === "string" ? e.message.content : "";
240
- const txt = stripContextWrappers(raw);
240
+ const txt = stripContextWrappers(raw).replace(/\s+/g, " ").trim();
241
241
  if (txt)
242
242
  return txt.slice(0, 80);
243
243
  }
@@ -7,10 +7,14 @@ import { type Skill } from "./skills.js";
7
7
  export declare function formatSkillsBlock(skills: Skill[]): string;
8
8
  export declare function loadGlobalAgentsMd(): string | null;
9
9
  /**
10
- * Static system prompt identical across all queries, cacheable.
11
- * Contains only identity and behavioral instructions.
10
+ * Identity paragraph one of the system prompt. Surface-agnostic, cacheable.
12
11
  */
13
- export declare const STATIC_SYSTEM_PROMPT: string;
12
+ export declare const STATIC_IDENTITY = "You are ash, an AI coding assistant running inside agent-sh \u2014 a composable agent runtime with a small core and everything else, including the frontend you're attached to, layered on as extensions.";
13
+ /**
14
+ * The rest of the static prompt — code map, tool guidance, envelope contract.
15
+ * Follows the frontend surface description in the assembled prompt.
16
+ */
17
+ export declare const STATIC_GUIDE: string;
14
18
  /**
15
19
  * CWD-scoped static context: project conventions (CLAUDE.md / AGENT.md)
16
20
  * and discovered skills. Stable for a given cwd — callers should cache
@@ -85,14 +85,14 @@ function loadConventionFiles(dir) {
85
85
  return result;
86
86
  }
87
87
  /**
88
- * Static system prompt identical across all queries, cacheable.
89
- * Contains only identity and behavioral instructions.
88
+ * Identity paragraph one of the system prompt. Surface-agnostic, cacheable.
90
89
  */
91
- export const STATIC_SYSTEM_PROMPT = `You are ash, an AI coding assistant running inside agent-sh — a composable agent runtime with a small core and everything else, including the shell integration, layered on as extensions.
92
-
93
- You may be paired with a terminal shell that shares the user's CWD, environment, and history in that mode you can read shell events and act on the user's session. Otherwise you may be embedded as a library, exposed over a bridge protocol, or running headless, with no shell available; in those modes you operate purely through your registered tools.
94
-
95
- agent-sh source and documentation live at ${CODE_DIR}. Read them when you need to understand how the runtime works, or when the user asks how to modify or extend it:
90
+ export const STATIC_IDENTITY = `You are ash, an AI coding assistant running inside agent-sh — a composable agent runtime with a small core and everything else, including the frontend you're attached to, layered on as extensions.`;
91
+ /**
92
+ * The rest of the static promptcode map, tool guidance, envelope contract.
93
+ * Follows the frontend surface description in the assembled prompt.
94
+ */
95
+ export const STATIC_GUIDE = `agent-sh source and documentation live at ${CODE_DIR}. Read them when you need to understand how the runtime works, or when the user asks how to modify or extend it:
96
96
  - ${path.join(CODE_DIR, "docs")} — start with README.md; architecture.md and extensions.md cover the kernel boundary and extension API
97
97
  - ${path.join(CODE_DIR, "src")} — kernel in src/core, default backend in src/agent, shell host in src/shell, built-in extensions in src/extensions
98
98
  - ${path.join(CODE_DIR, "examples/extensions")} — reference extensions to study or copy when adding functionality
@@ -105,15 +105,12 @@ guidance rather than assuming a particular tool exists. Tool output is
105
105
  returned to you for reasoning — the user doesn't see it directly.
106
106
 
107
107
  # Context Envelopes
108
- - \`<query_context>\` (contains \`<cwd>\` always, and \`<shell_events>\` when there were user shell commands since the last turn): the user's situation when they sent this turn — \`<cwd>\` anchors where they are right now, \`<shell_events>\` grounds "fix this" / "what just happened" requests. Trust the most recent \`<cwd>\` over any cwd referenced in earlier history.
109
- - \`<dynamic_context>\`: current system state — in-flight work, mode markers, warnings.
110
- \`<dynamic_context>\` may be absent on any turn.
111
108
 
112
- # Preference Learning
109
+ A turn may be preceded by either of two wrappers:
110
+ - \`<query_context>\`: the user's situation when they sent this turn — the frontend and extensions inject what grounds the request here. Trust the most recent values over anything referenced earlier in history.
111
+ - \`<dynamic_context>\`: current system state — in-flight work, mode markers, warnings.
113
112
 
114
- Treat the user's past commands as standing preferences. Before acting, check shell history
115
- and conversation context for recurring patterns — apply them proactively and do not wait to
116
- be reminded.`;
113
+ Either may be absent on any turn.`;
117
114
  /**
118
115
  * CWD-scoped static context: project conventions (CLAUDE.md / AGENT.md)
119
116
  * and discovered skills. Stable for a given cwd — callers should cache
@@ -84,7 +84,6 @@ export class InlineToolProtocol {
84
84
  const name = obj.tool;
85
85
  if (typeof name !== "string")
86
86
  continue;
87
- // Separate tool name from args
88
87
  const { tool: _, ...args } = obj;
89
88
  calls.push({
90
89
  id: `inline_${++this.callCounter}`,
@@ -128,7 +127,6 @@ class CodeBlockFilter {
128
127
  let raw = "";
129
128
  while (this.buf.length > 0) {
130
129
  if (this.inFence) {
131
- // Look for closing ```
132
130
  const closeIdx = this.buf.indexOf("```");
133
131
  if (closeIdx !== -1) {
134
132
  // Skip past closing ``` and any trailing whitespace on that line
@@ -142,7 +140,6 @@ class CodeBlockFilter {
142
140
  // No closing yet — keep buffering
143
141
  break;
144
142
  }
145
- // Look for opening ```tool
146
143
  const openIdx = this.buf.indexOf("```tool");
147
144
  if (openIdx !== -1) {
148
145
  // Emit everything before the fence, trimming trailing newline
@@ -184,7 +181,6 @@ class CodeBlockFilter {
184
181
  raw += this.buf;
185
182
  this.buf = "";
186
183
  }
187
- // Collapse runs of 3+ newlines into 2 (one blank line max)
188
184
  return this.collapseNewlines(raw);
189
185
  }
190
186
  flush() {
@@ -209,7 +205,6 @@ class CodeBlockFilter {
209
205
  prefix = "\n".repeat(Math.min(leading, allowed));
210
206
  text = text.slice(leading);
211
207
  }
212
- // Collapse internal runs
213
208
  text = text.replace(/\n{3,}/g, "\n\n");
214
209
  // Track trailing newlines for next call
215
210
  let trailing = 0;
@@ -322,9 +317,7 @@ export class DeferredToolProtocol {
322
317
  if (schemaProps) {
323
318
  const validParams = new Set(Object.keys(schemaProps));
324
319
  const providedParams = Object.keys(targetArgs);
325
- // Check for unknown params (likely wrong names)
326
320
  const unknown = providedParams.filter((p) => !validParams.has(p));
327
- // Check for missing required params
328
321
  const missing = [...requiredParams].filter((p) => !targetArgs[p]);
329
322
  if (unknown.length > 0 || missing.length > 0) {
330
323
  const expected = [...validParams]
package/dist/cli/args.js CHANGED
@@ -3,9 +3,10 @@ const HELP_TEXT = `agent-sh — a shell-first terminal where AI is one keystroke
3
3
 
4
4
  Usage: agent-sh [options]
5
5
  agent-sh init [--force] Scaffold ~/.agent-sh/ (settings, examples, AGENTS.md)
6
- agent-sh install <spec> [--force] [--sync-deps]
6
+ agent-sh install <spec> [--force] [--sync-deps] [--dev]
7
7
  Install an extension (bundled name, file:, npm:, github:)
8
8
  --sync-deps rewrites a stale agent-sh pin to the host version
9
+ --dev links the extension against the running host's core (local development)
9
10
  agent-sh uninstall <name> Remove an installed extension
10
11
  agent-sh list List installed extensions
11
12
  agent-sh auth login [provider] Store an API key for a built-in provider
@@ -1,6 +1,7 @@
1
1
  interface InstallOpts {
2
2
  force?: boolean;
3
3
  syncDeps?: boolean;
4
+ dev?: boolean;
4
5
  }
5
6
  export declare function listBundled(): string[];
6
7
  /** Heuristic: a backend named "pi" is typically provided by an extension called "pi-bridge". */
@@ -190,6 +190,32 @@ function rewriteFileDeps(target, sourcePath) {
190
190
  if (changed)
191
191
  fs.writeFileSync(pkgJson, `${JSON.stringify(pkg, null, 2)}\n`);
192
192
  }
193
+ /** --dev: repoint the extension's agent-sh dep at the running host's package
194
+ * root, so the install builds and runs against the local (possibly unreleased)
195
+ * core instead of the published registry version. npm links the file: path, so
196
+ * later core rebuilds flow through without reinstalling. */
197
+ function pinHostCore(target) {
198
+ const pkgJson = path.join(target, "package.json");
199
+ if (!fs.existsSync(pkgJson))
200
+ return;
201
+ const pkg = JSON.parse(fs.readFileSync(pkgJson, "utf-8"));
202
+ const sections = ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"];
203
+ let changed = false;
204
+ for (const section of sections) {
205
+ const deps = pkg[section];
206
+ if (!deps || typeof deps !== "object")
207
+ continue;
208
+ const d = deps;
209
+ if (typeof d["agent-sh"] !== "string")
210
+ continue;
211
+ d["agent-sh"] = `file:${PACKAGE_ROOT}`;
212
+ changed = true;
213
+ }
214
+ if (!changed)
215
+ return;
216
+ fs.writeFileSync(pkgJson, `${JSON.stringify(pkg, null, 2)}\n`);
217
+ console.log(`agent-sh: --dev — linking ${path.basename(target)} against host core at ${PACKAGE_ROOT}`);
218
+ }
193
219
  function maybeNpmInstall(target, pkg) {
194
220
  const deps = { ...(pkg.dependencies ?? {}), ...(pkg.peerDependencies ?? {}) };
195
221
  if (Object.keys(deps).length === 0)
@@ -252,7 +278,7 @@ function linkBins(target, pkg) {
252
278
  }
253
279
  export async function runInstall(spec, opts = {}) {
254
280
  if (!spec) {
255
- console.error("Usage: agent-sh install <name|file:|npm:|github:> [--force] [--sync-deps]\n\n" +
281
+ console.error("Usage: agent-sh install <name|file:|npm:|github:> [--force] [--sync-deps] [--dev]\n\n" +
256
282
  "Bundled extensions:\n" +
257
283
  listBundled()
258
284
  .map((n) => ` ${n}`)
@@ -286,6 +312,8 @@ export async function runInstall(spec, opts = {}) {
286
312
  });
287
313
  try {
288
314
  rewriteFileDeps(target, resolved.sourcePath);
315
+ if (opts.dev)
316
+ pinHostCore(target);
289
317
  syncAgentShVersion(target, opts.syncDeps ?? false);
290
318
  const pkg = readPackageJson(target);
291
319
  if (pkg) {
@@ -6,6 +6,7 @@ const SUBCOMMANDS = {
6
6
  install: (args) => runInstall(args[0] ?? "", {
7
7
  force: args.includes("--force"),
8
8
  syncDeps: args.includes("--sync-deps"),
9
+ dev: args.includes("--dev"),
9
10
  }),
10
11
  uninstall: (args) => runUninstall(args[0] ?? ""),
11
12
  list: () => runList(),
@@ -159,9 +159,7 @@ export class EventBus {
159
159
  * returns the original payload unchanged (with safe defaults).
160
160
  */
161
161
  async emitPipeAsync(event, payload) {
162
- // Phase 1: notify (lets renderers prepare for interactive I/O)
163
162
  this.dispatch(event, payload);
164
- // Phase 2: transform (extensions provide decisions)
165
163
  const listeners = this.asyncPipeListeners.get(event);
166
164
  if (!listeners)
167
165
  return payload;
@@ -119,7 +119,9 @@ function createScopedContext(ctx, extensionName) {
119
119
  onDispose: (fn) => { cleanups.push(fn); },
120
120
  };
121
121
  const dispose = () => {
122
- for (const fn of cleanups) {
122
+ // Snapshot: a re-registering cleanup appends a new cleanup, and iterating
123
+ // the live array would run it and undo the restore in the same pass.
124
+ for (const fn of cleanups.slice()) {
123
125
  try {
124
126
  fn();
125
127
  }
@@ -13,7 +13,7 @@ import { HandlerRegistry } from "../utils/handler-registry.js";
13
13
  export { EventBus } from "./event-bus.js";
14
14
  export type { BusEvents, ContentBlock, BackendRegistration } from "./event-bus.js";
15
15
  export type { CoreContext, CoreConfig } from "./types.js";
16
- export type { AgentContext, AgentConfig, AgentSurface, AgentConfigSurface, AgentMode, LlmInterface, LlmMessage, LlmSession } from "../agent/host-types.js";
16
+ export type { AgentContext, AgentConfig, AgentSurface, AgentConfigSurface, Model, LlmInterface, LlmMessage, LlmSession } from "../agent/host-types.js";
17
17
  export type { ShellContext, ShellConfig, ShellSurface, ShellConfigSurface, ExtensionContext, RemoteSession, RemoteSessionOptions, RenderSurface, InputModeConfig, TerminalSession, BlockTransformOptions, FencedBlockTransformOptions, AppConfig } from "../shell/host-types.js";
18
18
  export { palette, setPalette, resetPalette } from "../utils/palette.js";
19
19
  export type { ColorPalette } from "../utils/palette.js";
@@ -29,10 +29,11 @@ export function createCore(config) {
29
29
  bus.setSource(instanceId);
30
30
  handlers.define("config:get-app-config", () => config);
31
31
  handlers.define("cwd", () => process.cwd());
32
- // Empty defaults so registerContextProducer can advise regardless of
33
- // backend. Each backend chooses how to consume the strings.
32
+ // Empty defaults so advisors can wrap these regardless of load order;
33
+ // system-prompt:frontend is where the active frontend describes its surface.
34
34
  handlers.define("dynamic-context:build", () => "");
35
35
  handlers.define("query-context:build", () => "");
36
+ handlers.define("system-prompt:frontend", () => "");
36
37
  const backends = new Map();
37
38
  let activeBackendName = null;
38
39
  bus.on("agent:register-backend", (backend) => {
@@ -41,16 +41,21 @@ export default function activate(ctx) {
41
41
  description: "Cycle to next model, or switch to a specific one",
42
42
  handler: (args) => {
43
43
  const name = args.trim();
44
+ const { models, active } = bus.emitPipe("config:get-models", { models: [], active: null });
44
45
  if (!name) {
45
- const { active } = bus.emitPipe("config:get-models", { models: [], active: null });
46
- const label = active
47
- ? `${active.model}${active.provider ? ` [${active.provider}]` : ""}`
48
- : "none";
46
+ const label = active ? `${active.id} [${active.provider}]` : "none";
49
47
  bus.emit("ui:info", { message: `Model: ${label}` });
48
+ return;
50
49
  }
51
- else {
52
- bus.emit("config:switch-model", { model: name });
50
+ const atIdx = name.lastIndexOf("@");
51
+ const id = atIdx > 0 ? name.slice(0, atIdx) : name;
52
+ const providerHint = atIdx > 0 ? name.slice(atIdx + 1) : undefined;
53
+ const found = models.find((m) => m.id === id && (!providerHint || m.provider === providerHint));
54
+ if (!found) {
55
+ bus.emit("ui:error", { message: `Unknown model: ${name}` });
56
+ return;
53
57
  }
58
+ bus.emit("config:switch-model", { id: found.id, provider: found.provider });
54
59
  },
55
60
  });
56
61
  register({
@@ -163,16 +168,16 @@ export default function activate(ctx) {
163
168
  const { models, active } = bus.emitPipe("config:get-models", { models: [], active: null });
164
169
  const counts = new Map();
165
170
  for (const m of models)
166
- counts.set(m.model, (counts.get(m.model) ?? 0) + 1);
171
+ counts.set(m.id, (counts.get(m.id) ?? 0) + 1);
167
172
  const items = models
168
- .filter((m) => m.model.toLowerCase().includes(partial))
173
+ .filter((m) => m.id.toLowerCase().includes(partial))
169
174
  .slice(0, 15)
170
175
  .map((m) => {
171
- const ambiguous = (counts.get(m.model) ?? 0) > 1 && m.provider;
172
- const qualified = ambiguous ? `${m.model}@${m.provider}` : m.model;
176
+ const ambiguous = (counts.get(m.id) ?? 0) > 1;
177
+ const qualified = ambiguous ? `${m.id}@${m.provider}` : m.id;
173
178
  return {
174
179
  name: `/model ${qualified}`,
175
- description: `${m.provider ? `[${m.provider}]` : ""}${active && m.model === active.model && m.provider === active.provider ? " (active)" : ""}`,
180
+ description: `[${m.provider}]${active && m.id === active.id && m.provider === active.provider ? " (active)" : ""}`,
176
181
  };
177
182
  });
178
183
  if (items.length === 0)
@@ -7,11 +7,13 @@ import "./events.js"; // augments BusEvents with shell-owned events
7
7
  import { Shell } from "./shell.js";
8
8
  import { DefaultCompositor } from "../utils/compositor.js";
9
9
  import { TerminalBuffer } from "../utils/terminal-buffer.js";
10
+ import { FloatingPanel } from "../utils/floating-panel.js";
10
11
  import { setPalette } from "../utils/palette.js";
11
12
  import * as streamTransform from "../utils/stream-transform.js";
12
13
  import activateShellContext from "./shell-context.js";
13
14
  import activateTuiRenderer from "./tui-renderer.js";
14
15
  import { processTerminal, surfaceFromTerminal } from "./terminal.js";
16
+ const SHELL_SURFACE = `You're attached through a terminal shell. It shares the user's working directory, environment, and command history, and you can act on their live session — everything they run at the prompt is visible to you.`;
15
17
  /**
16
18
  * Register shell-owned handlers extensions can `ctx.call`, and attach
17
19
  * the shell surface to ctx. Must run before `loadExtensions` so user
@@ -20,6 +22,10 @@ import { processTerminal, surfaceFromTerminal } from "./terminal.js";
20
22
  export function registerShellHandlers(ctx) {
21
23
  const { bus } = ctx;
22
24
  const compositor = new DefaultCompositor(bus);
25
+ ctx.advise("system-prompt:frontend", (next) => {
26
+ const base = next() ?? "";
27
+ return base ? `${base}\n\n${SHELL_SURFACE}` : SHELL_SURFACE;
28
+ });
23
29
  const shellSurface = {
24
30
  compositor,
25
31
  setPalette,
@@ -69,6 +75,9 @@ export function registerShellHandlers(ctx) {
69
75
  terminalBufferSingleton = TerminalBuffer.createWired(ctx.bus);
70
76
  return terminalBufferSingleton;
71
77
  });
78
+ // bus override lets callers pass their scoped bus, so the panel's
79
+ // listeners unwire when the extension reloads.
80
+ ctx.define("floating-panel:create", (config, bus) => new FloatingPanel(bus ?? ctx.bus, config));
72
81
  activateShellContext(ctx);
73
82
  activateTuiRenderer(ctx);
74
83
  }
@@ -1,5 +1,5 @@
1
1
  /** Tracks PTY commands and cwd, spills long outputs, contributes per-query
2
- * `<cwd>` (always) and `<shell_events>` (fresh user exchanges). Frontends
3
- * without a PTY skip this and fall back to core's process.cwd() default. */
2
+ * `<shell_events>` (fresh user exchanges) and — under the shell frontend —
3
+ * `<cwd>`. Frontends without a PTY skip this. */
4
4
  import type { ExtensionContext } from "./host-types.js";
5
5
  export default function activate(ctx: ExtensionContext): void;