agent-sh 0.12.12 → 0.12.14

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.
@@ -13,7 +13,6 @@
13
13
  */
14
14
  import type { EventBus } from "../event-bus.js";
15
15
  import type { AgentMode } from "../types.js";
16
- import type { ContextManager } from "../context-manager.js";
17
16
  import type { LlmClient } from "../utils/llm-client.js";
18
17
  import type { HandlerFunctions } from "../utils/handler-registry.js";
19
18
  import type { AgentBackend, ToolDefinition } from "./types.js";
@@ -21,7 +20,6 @@ import { type HistoryAdapter } from "./history-file.js";
21
20
  import type { Compositor } from "../utils/compositor.js";
22
21
  export interface AgentLoopConfig {
23
22
  bus: EventBus;
24
- contextManager: ContextManager;
25
23
  llmClient: LlmClient;
26
24
  handlers: HandlerFunctions;
27
25
  modes?: AgentMode[];
@@ -57,14 +55,12 @@ export declare class AgentLoop implements AgentBackend {
57
55
  private lastErrorByFile;
58
56
  private static readonly THINKING_LEVELS;
59
57
  private bus;
60
- private contextManager;
61
58
  private llmClient;
62
59
  private handlers;
63
60
  private thinkingLevel;
64
61
  private compositor;
65
62
  private toolProtocol;
66
63
  private instanceId;
67
- private lastShellSeq;
68
64
  constructor(config: AgentLoopConfig);
69
65
  /** Subscribe to bus events — activates this backend. */
70
66
  wire(): void;
@@ -7,15 +7,17 @@ import { ToolRegistry } from "./tool-registry.js";
7
7
  import { ConversationState } from "./conversation-state.js";
8
8
  import { HistoryFile } from "./history-file.js";
9
9
  import { nucleate, formatNuclearLine, isReadOnly } from "./nuclear-form.js";
10
- import { STATIC_SYSTEM_PROMPT, buildDynamicContext, buildStaticByCwd, formatSkillsBlock, loadGlobalAgentsMd } from "./system-prompt.js";
10
+ import { STATIC_SYSTEM_PROMPT, buildStaticByCwd, formatSkillsBlock, loadGlobalAgentsMd } from "./system-prompt.js";
11
11
  import { createToolUI } from "../utils/tool-interactive.js";
12
12
  import { RESPONSE_RESERVE, DEFAULT_CONTEXT_WINDOW } from "./token-budget.js";
13
13
  import { PACKAGE_VERSION } from "../utils/package-version.js";
14
+ import { wrapTrailingWithDynamicContext } from "../utils/message-utils.js";
14
15
  import { getSettings, updateSettings } from "../settings.js";
15
16
  import { createToolProtocol } from "./tool-protocol.js";
16
17
  // Core tool factories
17
18
  import { createBashTool } from "./tools/bash.js";
18
19
  import { createPwshTool } from "./tools/pwsh.js";
20
+ import { findBash } from "../executor.js";
19
21
  import { createReadFileTool } from "./tools/read-file.js";
20
22
  import { createWriteFileTool } from "./tools/write-file.js";
21
23
  import { createEditFileTool } from "./tools/edit-file.js";
@@ -88,20 +90,14 @@ export class AgentLoop {
88
90
  lastErrorByFile = new Map(); // file path → error summary
89
91
  static THINKING_LEVELS = ["off", "low", "medium", "high"];
90
92
  bus;
91
- contextManager;
92
93
  llmClient;
93
94
  handlers;
94
95
  thinkingLevel = "off";
95
96
  compositor = null;
96
97
  toolProtocol;
97
98
  instanceId;
98
- // Cursor into ContextManager's exchange stream. Events with id > this
99
- // have not yet been shown to the LLM. We inject the delta as a user
100
- // message before each stream so the prefix stays cacheable.
101
- lastShellSeq = 0;
102
99
  constructor(config) {
103
100
  this.bus = config.bus;
104
- this.contextManager = config.contextManager;
105
101
  this.llmClient = config.llmClient;
106
102
  this.handlers = config.handlers;
107
103
  this.compositor = config.compositor ?? null;
@@ -597,7 +593,7 @@ export class AgentLoop {
597
593
  return `${raw}${context}`;
598
594
  }
599
595
  registerCoreTools() {
600
- const getCwd = () => this.contextManager.getCwd();
596
+ const getCwd = () => this.handlers.call("cwd");
601
597
  const getEnv = () => {
602
598
  const env = {};
603
599
  for (const [k, v] of Object.entries(process.env)) {
@@ -606,7 +602,9 @@ export class AgentLoop {
606
602
  }
607
603
  return env;
608
604
  };
609
- this.toolRegistry.register(createBashTool({ getCwd, getEnv, bus: this.bus }));
605
+ if (findBash() !== null) {
606
+ this.toolRegistry.register(createBashTool({ getCwd, getEnv, bus: this.bus }));
607
+ }
610
608
  if (process.platform === "win32") {
611
609
  this.toolRegistry.register(createPwshTool({ getCwd, getEnv, bus: this.bus }));
612
610
  }
@@ -714,7 +712,7 @@ export class AgentLoop {
714
712
  // Placed here so they enter the provider's prompt cache with the
715
713
  // system prompt, and only re-materialize when cwd changes invalidate
716
714
  // cachedSystemPrompt in executeLoop.
717
- const projectStatic = buildStaticByCwd(this.contextManager.getCwd());
715
+ const projectStatic = buildStaticByCwd(this.handlers.call("cwd"));
718
716
  if (projectStatic)
719
717
  parts.push(projectStatic);
720
718
  // Extension sections (tools, skills, instructions grouped by extension)
@@ -790,12 +788,9 @@ export class AgentLoop {
790
788
  };
791
789
  });
792
790
  h.define("agent:get-self", () => this);
793
- // Extensions compose additional context (git info, project rules, etc.)
794
- h.define("dynamic-context:build", () => {
795
- const contextWindow = this.currentMode.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
796
- const promptTokens = this.conversation.estimatePromptTokens();
797
- return buildDynamicContext(this.contextManager, { promptTokens, contextWindow });
798
- });
791
+ // dynamic-context:build / query-context:build are defined in core.ts.
792
+ // ash consumes them via the envelope wrapping in streamResponse +
793
+ // handleQuery; other backends may ignore.
799
794
  // Full control over what the LLM sees: takes messages[], returns messages[].
800
795
  // Default: pass through. Extensions can advise to compact, summarize,
801
796
  // filter, reorder, inject — whatever strategy fits.
@@ -1029,14 +1024,14 @@ export class AgentLoop {
1029
1024
  this.bus.emit("agent:processing-start", {});
1030
1025
  let responseText = "";
1031
1026
  try {
1032
- // Prepend any shell events that preceded this query into the same
1033
- // user message, so the conversation reads chronologically and we
1034
- // don't emit two consecutive user-role messages (some providers
1035
- // reject that).
1036
- const preDelta = this.contextManager.getEventsSince(this.lastShellSeq);
1037
- const userContent = preDelta ? `${preDelta.text}\n\n${query}` : query;
1038
- if (preDelta)
1039
- this.lastShellSeq = preDelta.lastSeq;
1027
+ // Per-query producers (shell events + any extension-registered
1028
+ // per-query signals) produce content that gets frozen into this
1029
+ // user message inside <query_context>, distinguishing it from the
1030
+ // per-request <dynamic_context> wrapped on the trailing message.
1031
+ const queryContext = (this.handlers.call("query-context:build") ?? "").trim();
1032
+ const userContent = queryContext
1033
+ ? `<query_context>\n${queryContext}\n</query_context>\n\n${query}`
1034
+ : query;
1040
1035
  this.conversation.addUserMessage(userContent);
1041
1036
  this.bus.emit("conversation:message-appended", { role: "user", content: query });
1042
1037
  responseText = await this.executeLoop(signal);
@@ -1080,7 +1075,7 @@ export class AgentLoop {
1080
1075
  // so live signals (budget, in-flight subagents, metacognitive warnings)
1081
1076
  // are fresh.
1082
1077
  let cachedSystemPrompt;
1083
- let lastCwd = this.contextManager.getCwd();
1078
+ let lastCwd = this.handlers.call("cwd");
1084
1079
  while (!signal.aborted) {
1085
1080
  // Auto-compact when total context approaches the window limit.
1086
1081
  const totalEstimate = this.conversation.estimatePromptTokens();
@@ -1101,7 +1096,7 @@ export class AgentLoop {
1101
1096
  }
1102
1097
  cachedSystemPrompt = undefined;
1103
1098
  }
1104
- const currentCwd = this.contextManager.getCwd();
1099
+ const currentCwd = this.handlers.call("cwd");
1105
1100
  if (currentCwd !== lastCwd) {
1106
1101
  cachedSystemPrompt = undefined;
1107
1102
  lastCwd = currentCwd;
@@ -1516,24 +1511,17 @@ export class AgentLoop {
1516
1511
  let reasoning = "";
1517
1512
  const reasoningDetailsByIndex = new Map();
1518
1513
  const pendingToolCalls = [];
1514
+ // Tool protocol controls what goes in the API tools param vs dynamic context
1515
+ const apiTools = this.toolProtocol.getApiTools(this.toolRegistry.all());
1516
+ const toolPrompt = this.toolProtocol.getToolPrompt(this.toolRegistry.all());
1517
+ // Dynamic context rides on the trailing message — see
1518
+ // wrapTrailingWithDynamicContext for the cache-stability rationale.
1519
1519
  const rawMessages = [
1520
1520
  { role: "system", content: systemPrompt },
1521
- { role: "user", content: `<context>\n${dynamicContext}\n</context>` },
1522
- { role: "assistant", content: "Understood." },
1523
- ...this.conversation.getMessages(),
1521
+ ...wrapTrailingWithDynamicContext(this.conversation.getMessages(), dynamicContext, toolPrompt),
1524
1522
  ];
1525
1523
  // Let extensions transform the message array (compact, summarize, filter, etc.)
1526
1524
  const messages = this.handlers.call("conversation:prepare", rawMessages);
1527
- // Tool protocol controls what goes in the API tools param vs dynamic context
1528
- const apiTools = this.toolProtocol.getApiTools(this.toolRegistry.all());
1529
- const toolPrompt = this.toolProtocol.getToolPrompt(this.toolRegistry.all());
1530
- // Append tool catalog to dynamic context (closer to user query = better followed)
1531
- if (toolPrompt) {
1532
- const ctxMsg = messages[1]; // dynamic context user message
1533
- if (ctxMsg && typeof ctxMsg.content === "string") {
1534
- ctxMsg.content += "\n" + toolPrompt;
1535
- }
1536
- }
1537
1525
  // Stream filter strips tool tags from display (inline mode only)
1538
1526
  const streamFilter = this.toolProtocol.createStreamFilter(this.toolRegistry.all().map((t) => t.name));
1539
1527
  const requestParams = {
@@ -9,7 +9,9 @@ export declare function discoverGlobalSkills(): Skill[];
9
9
  export declare function invalidateGlobalSkillsCache(): void;
10
10
  /**
11
11
  * Discover project-level skills from .agents/skills/ in cwd hierarchy.
12
- * Scans from cwd up to git root.
12
+ * Walks from cwd up to $HOME (or filesystem root if cwd is outside HOME).
13
+ * Git boundaries are ignored — nested repos under a skills-bearing parent
14
+ * would otherwise hide the parent's skills.
13
15
  */
14
16
  export declare function discoverProjectSkills(cwd: string): Skill[];
15
17
  /**
@@ -93,22 +93,6 @@ function scanDir(dir) {
93
93
  }
94
94
  return skills;
95
95
  }
96
- /** Find the git root from a directory. */
97
- function findGitRoot(dir) {
98
- let current = path.resolve(dir);
99
- while (true) {
100
- try {
101
- fs.accessSync(path.join(current, ".git"));
102
- return current;
103
- }
104
- catch {
105
- const parent = path.dirname(current);
106
- if (parent === current)
107
- return null;
108
- current = parent;
109
- }
110
- }
111
- }
112
96
  /** Expand ~ to home directory. */
113
97
  function expandHome(p) {
114
98
  if (p.startsWith("~/") || p === "~") {
@@ -146,16 +130,18 @@ export function invalidateGlobalSkillsCache() {
146
130
  }
147
131
  /**
148
132
  * Discover project-level skills from .agents/skills/ in cwd hierarchy.
149
- * Scans from cwd up to git root.
133
+ * Walks from cwd up to $HOME (or filesystem root if cwd is outside HOME).
134
+ * Git boundaries are ignored — nested repos under a skills-bearing parent
135
+ * would otherwise hide the parent's skills.
150
136
  */
151
137
  export function discoverProjectSkills(cwd) {
152
138
  const seen = new Set();
153
139
  const skills = [];
154
- const gitRoot = findGitRoot(cwd);
140
+ const home = path.resolve(os.homedir());
155
141
  let current = path.resolve(cwd);
156
142
  while (true) {
157
143
  addUnique(skills, scanDir(path.join(current, ".agents", "skills")), seen);
158
- if (gitRoot && current === gitRoot)
144
+ if (current === home)
159
145
  break;
160
146
  const parent = path.dirname(current);
161
147
  if (parent === current)
@@ -1,4 +1,5 @@
1
1
  import { ConversationState } from "./conversation-state.js";
2
+ import { wrapTrailingWithDynamicContext } from "../utils/message-utils.js";
2
3
  /**
3
4
  * Run a subagent to completion.
4
5
  * Returns the final response text.
@@ -99,15 +100,11 @@ async function streamOnce(llmClient, systemPrompt, conversation, apiTools, model
99
100
  const reasoningDetailsByIndex = new Map();
100
101
  const pendingToolCalls = [];
101
102
  let usage = null;
102
- const messages = [
103
- { role: "system", content: systemPrompt },
104
- ];
105
- if (dynamicContext) {
106
- messages.push({ role: "user", content: `<context>\n${dynamicContext}\n</context>` });
107
- messages.push({ role: "assistant", content: "Understood." });
108
- }
109
103
  const stream = await llmClient.stream({
110
- messages: [...messages, ...conversation.getMessages()],
104
+ messages: [
105
+ { role: "system", content: systemPrompt },
106
+ ...wrapTrailingWithDynamicContext(conversation.getMessages(), dynamicContext ?? ""),
107
+ ],
111
108
  tools: apiTools.length > 0 ? apiTools : undefined,
112
109
  model,
113
110
  signal,
@@ -1,4 +1,3 @@
1
- import type { ContextManager } from "../context-manager.js";
2
1
  import { type Skill } from "./skills.js";
3
2
  /**
4
3
  * Format skills for inline display in prompt.
@@ -12,35 +11,9 @@ export declare function loadGlobalAgentsMd(): string | null;
12
11
  * Contains only identity and behavioral instructions.
13
12
  */
14
13
  export declare const STATIC_SYSTEM_PROMPT: string;
15
- /**
16
- * Build the dynamic context — injected as a user message before each query.
17
- * Contains everything that changes: shell context, conventions, cwd.
18
- *
19
- * Runs through the "dynamic-context:build" handler so extensions can advise.
20
- */
21
- export interface TokenStatus {
22
- /** Estimated prompt tokens (API-grounded when available, else chars/4). */
23
- promptTokens: number;
24
- /** Model's context window in tokens. */
25
- contextWindow: number;
26
- }
27
14
  /**
28
15
  * CWD-scoped static context: project conventions (CLAUDE.md / AGENT.md)
29
16
  * and discovered skills. Stable for a given cwd — callers should cache
30
17
  * on cwd identity rather than rebuilding per LLM iteration.
31
18
  */
32
19
  export declare function buildStaticByCwd(cwd: string): string;
33
- /**
34
- * Per-iteration dynamic context: date, working directory, token usage.
35
- * Rebuilt every LLM call. Extension advisors add more sections (budget,
36
- * subagents, metacognitive signals, etc.) on top.
37
- *
38
- * Skills, AGENTS.md, and project conventions live in the system prompt
39
- * (see `system-prompt:build` in agent-loop) so they enter the provider's
40
- * prefix cache instead of being rebuilt and re-sent every turn.
41
- *
42
- * Shell context is likewise not injected here — it flows into the
43
- * conversation as incremental <shell-events> messages (see
44
- * AgentLoop.injectShellDelta) for the same reason.
45
- */
46
- export declare function buildDynamicContext(contextManager: ContextManager, tokenStatus: TokenStatus): string;
@@ -107,6 +107,11 @@ Extensions may register additional tools — follow their instructions.
107
107
  - Keep bash commands focused; avoid long-running blocking commands
108
108
  - Always check command exit codes for errors
109
109
 
110
+ # Context Envelopes
111
+ - \`<query_context>\` (e.g. \`<shell_events>\`): the user's situation when they sent this turn — ground "fix this" / "what just happened" requests with it.
112
+ - \`<dynamic_context>\`: current system state — in-flight work, mode markers, warnings.
113
+ Either may be absent on any turn.
114
+
110
115
  # Preference Learning
111
116
 
112
117
  Treat the user's past commands as standing preferences. Before acting, check shell history
@@ -130,27 +135,3 @@ export function buildStaticByCwd(cwd) {
130
135
  }
131
136
  return sections.join("\n\n");
132
137
  }
133
- /**
134
- * Per-iteration dynamic context: date, working directory, token usage.
135
- * Rebuilt every LLM call. Extension advisors add more sections (budget,
136
- * subagents, metacognitive signals, etc.) on top.
137
- *
138
- * Skills, AGENTS.md, and project conventions live in the system prompt
139
- * (see `system-prompt:build` in agent-loop) so they enter the provider's
140
- * prefix cache instead of being rebuilt and re-sent every turn.
141
- *
142
- * Shell context is likewise not injected here — it flows into the
143
- * conversation as incremental <shell-events> messages (see
144
- * AgentLoop.injectShellDelta) for the same reason.
145
- */
146
- export function buildDynamicContext(contextManager, tokenStatus) {
147
- const envLines = [
148
- `Current date: ${new Date().toISOString().split("T")[0]}`,
149
- `Working directory: ${contextManager.getCwd()}`,
150
- ];
151
- const usedK = (tokenStatus.promptTokens / 1000).toFixed(1);
152
- const maxK = (tokenStatus.contextWindow / 1000).toFixed(0);
153
- const pct = Math.min(100, Math.round((tokenStatus.promptTokens / tokenStatus.contextWindow) * 100));
154
- envLines.push(`Token usage: ${usedK}k/${maxK}k (${pct}%)`);
155
- return `<environment>\n${envLines.join("\n")}\n</environment>`;
156
- }
package/dist/core.d.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Core kernel — the minimum viable agent-sh.
3
3
  *
4
- * Wires up EventBus + ContextManager without any frontend or agent backend.
4
+ * Wires up EventBus + HandlerRegistry without any frontend or agent backend.
5
5
  * Consumers attach their own I/O (Shell, WebSocket, REST, tests) by
6
- * subscribing to bus events.
6
+ * subscribing to bus events. Shell-specific tracking lives in the
7
+ * shell-context built-in extension.
7
8
  *
8
9
  * Agent backends are loaded as extensions and register themselves via
9
10
  * the agent:register-backend bus event. The built-in "ash" backend is
@@ -17,7 +18,6 @@
17
18
  * const response = await core.query("hello");
18
19
  */
19
20
  import { EventBus } from "./event-bus.js";
20
- import { ContextManager } from "./context-manager.js";
21
21
  import type { AgentShellConfig, ExtensionContext } from "./types.js";
22
22
  import { HandlerRegistry } from "./utils/handler-registry.js";
23
23
  export { EventBus } from "./event-bus.js";
@@ -32,7 +32,6 @@ export { HistoryFile, InMemoryHistory, NoopHistory, type HistoryAdapter } from "
32
32
  export type { NuclearEntry } from "./agent/nuclear-form.js";
33
33
  export interface AgentShellCore {
34
34
  bus: EventBus;
35
- contextManager: ContextManager;
36
35
  /** Handler registry for define/advise/call. */
37
36
  handlers: HandlerRegistry;
38
37
  /** Unique id for this agent process; used for shell-marker tagging and lineage tracking. */
package/dist/core.js CHANGED
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Core kernel — the minimum viable agent-sh.
3
3
  *
4
- * Wires up EventBus + ContextManager without any frontend or agent backend.
4
+ * Wires up EventBus + HandlerRegistry without any frontend or agent backend.
5
5
  * Consumers attach their own I/O (Shell, WebSocket, REST, tests) by
6
- * subscribing to bus events.
6
+ * subscribing to bus events. Shell-specific tracking lives in the
7
+ * shell-context built-in extension.
7
8
  *
8
9
  * Agent backends are loaded as extensions and register themselves via
9
10
  * the agent:register-backend bus event. The built-in "ash" backend is
@@ -17,17 +18,15 @@
17
18
  * const response = await core.query("hello");
18
19
  */
19
20
  import { EventBus } from "./event-bus.js";
20
- import { ContextManager } from "./context-manager.js";
21
21
  import { createLlmFacade } from "./utils/llm-facade.js";
22
22
  import { setPalette } from "./utils/palette.js";
23
23
  import * as streamTransform from "./utils/stream-transform.js";
24
24
  import * as settingsMod from "./settings.js";
25
25
  import { HandlerRegistry } from "./utils/handler-registry.js";
26
- import { TerminalBuffer } from "./utils/terminal-buffer.js";
27
26
  import crypto from "node:crypto";
28
27
  import * as fs from "node:fs";
29
28
  import * as path from "node:path";
30
- import { DefaultCompositor, StdoutSurface } from "./utils/compositor.js";
29
+ import { DefaultCompositor } from "./utils/compositor.js";
31
30
  import { CONFIG_DIR } from "./settings.js";
32
31
  // Re-export types that library consumers need
33
32
  export { EventBus } from "./event-bus.js";
@@ -38,7 +37,6 @@ export { HistoryFile, InMemoryHistory, NoopHistory } from "./agent/history-file.
38
37
  export function createCore(config) {
39
38
  const bus = new EventBus();
40
39
  const handlers = new HandlerRegistry();
41
- const contextManager = new ContextManager(bus, handlers);
42
40
  // 3 bytes = 6 hex chars, ~16M values — ample for per-lineage uniqueness and
43
41
  // short enough to read/remember. Legacy content may have 16-char iids; any
44
42
  // parsers should accept ≥6 hex chars.
@@ -48,6 +46,14 @@ export function createCore(config) {
48
46
  // Expose raw CLI config so the agent backend extension can resolve
49
47
  // providers and create the LLM client.
50
48
  handlers.define("config:get-shell-config", () => config);
49
+ // Default; shell-context advises with the PTY-tracked cwd when loaded.
50
+ handlers.define("cwd", () => process.cwd());
51
+ // Empty defaults so registerContextProducer can advise regardless of
52
+ // backend. Each backend chooses whether to consume the strings — ash
53
+ // wraps them in <dynamic_context>/<query_context>; bridges may pull
54
+ // query-context:build and splice into the target SDK however they like.
55
+ handlers.define("dynamic-context:build", () => "");
56
+ handlers.define("query-context:build", () => "");
51
57
  const backends = new Map();
52
58
  let activeBackendName = null;
53
59
  const activateByName = async (name, silent = false) => {
@@ -91,22 +97,12 @@ export function createCore(config) {
91
97
  return { names, active: activeBackendName };
92
98
  });
93
99
  // ── Compositor ──────────────────────────────────────────────
100
+ // Generic surface-routing primitive. No defaults here — the active
101
+ // frontend (src/shell/, a web bridge, headless test harness, etc.)
102
+ // sets its own surfaces during activation.
94
103
  const compositor = new DefaultCompositor(bus);
95
- const stdoutSurface = new StdoutSurface();
96
- compositor.setDefault("agent", stdoutSurface);
97
- compositor.setDefault("query", stdoutSurface);
98
- compositor.setDefault("status", stdoutSurface);
99
- // ── Lazy singleton terminal buffer ──────────────────────────
100
- let terminalBufferSingleton; // undefined = not yet created
101
- const getTerminalBuffer = () => {
102
- if (terminalBufferSingleton !== undefined)
103
- return terminalBufferSingleton;
104
- terminalBufferSingleton = TerminalBuffer.createWired(bus);
105
- return terminalBufferSingleton;
106
- };
107
104
  return {
108
105
  bus,
109
- contextManager,
110
106
  handlers,
111
107
  instanceId,
112
108
  activateBackend() {
@@ -162,7 +158,6 @@ export function createCore(config) {
162
158
  extensionContext(opts) {
163
159
  const ctx = {
164
160
  bus,
165
- contextManager,
166
161
  instanceId,
167
162
  llm: createLlmFacade(handlers),
168
163
  providers: {
@@ -186,11 +181,25 @@ export function createCore(config) {
186
181
  removeInstruction: (name) => bus.emit("agent:remove-instruction", { name }),
187
182
  registerSkill: (name, description, filePath) => bus.emit("agent:register-skill", { name, description, filePath, extensionName: "" }),
188
183
  removeSkill: (name) => bus.emit("agent:remove-skill", { name }),
184
+ registerContextProducer: (_name, producer, opts) => {
185
+ const handlerName = opts?.mode === "per-query"
186
+ ? "query-context:build"
187
+ : "dynamic-context:build";
188
+ return handlers.advise(handlerName, (next) => {
189
+ const base = next();
190
+ const part = producer();
191
+ if (!part)
192
+ return base;
193
+ const trimmed = part.trim();
194
+ if (!trimmed)
195
+ return base;
196
+ return base ? `${base}\n\n${trimmed}` : trimmed;
197
+ });
198
+ },
189
199
  define: (name, fn) => handlers.define(name, fn),
190
200
  advise: (name, wrapper) => handlers.advise(name, wrapper),
191
201
  call: (name, ...args) => handlers.call(name, ...args),
192
202
  list: () => handlers.list(),
193
- get terminalBuffer() { return getTerminalBuffer(); },
194
203
  compositor,
195
204
  onDispose: () => { },
196
205
  createRemoteSession: (opts) => {
@@ -214,12 +223,6 @@ export function createCore(config) {
214
223
  if (opts.suppressUsage !== false) {
215
224
  cleanups.push(handlers.advise("tui:render-usage", (next, ...a) => active ? "" : next(...a)));
216
225
  }
217
- if (opts.interactive) {
218
- cleanups.push(handlers.advise("dynamic-context:build", (next) => {
219
- const base = next();
220
- return active ? base + "\ninteractive-session: true\n" : base;
221
- }));
222
- }
223
226
  return {
224
227
  submit(query) { bus.emit("agent:submit", { query }); },
225
228
  get surface() { return surface; },
@@ -1,4 +1,8 @@
1
1
  import { type ChildProcess } from "node:child_process";
2
+ /** Resolve a usable bash binary, or null if none is on PATH.
3
+ * Unix: `/bin/bash` (canonical, present on every Linux/macOS install).
4
+ * Windows: probe via `where bash` so Git Bash users keep working. */
5
+ export declare function findBash(): string | null;
2
6
  export interface ExecutorSession {
3
7
  id: string;
4
8
  command: string;
package/dist/executor.js CHANGED
@@ -1,5 +1,20 @@
1
- import { spawn } from "node:child_process";
1
+ import { spawn, spawnSync } from "node:child_process";
2
2
  import { stripAnsi } from "./utils/ansi.js";
3
+ let cachedBashPath;
4
+ /** Resolve a usable bash binary, or null if none is on PATH.
5
+ * Unix: `/bin/bash` (canonical, present on every Linux/macOS install).
6
+ * Windows: probe via `where bash` so Git Bash users keep working. */
7
+ export function findBash() {
8
+ if (cachedBashPath !== undefined)
9
+ return cachedBashPath;
10
+ if (process.platform !== "win32") {
11
+ cachedBashPath = "/bin/bash";
12
+ return cachedBashPath;
13
+ }
14
+ const r = spawnSync("where", ["bash"], { encoding: "utf-8" });
15
+ cachedBashPath = r.status === 0 ? r.stdout.split(/\r?\n/)[0].trim() || null : null;
16
+ return cachedBashPath;
17
+ }
3
18
  const DEFAULT_TIMEOUT = 60_000;
4
19
  const DEFAULT_MAX_OUTPUT = 256 * 1024; // 256KB
5
20
  /**
@@ -29,9 +44,12 @@ export function executeCommand(opts) {
29
44
  if (v !== undefined)
30
45
  env[k] = v;
31
46
  }
47
+ const bashPath = findBash();
32
48
  let child;
33
49
  try {
34
- child = spawn("/bin/bash", ["-c", opts.command], {
50
+ if (!bashPath)
51
+ throw new Error("bash not found on PATH");
52
+ child = spawn(bashPath, ["-c", opts.command], {
35
53
  stdio: ["ignore", "pipe", "pipe"],
36
54
  cwd: opts.cwd,
37
55
  env,
@@ -73,6 +73,12 @@ function createScopedContext(ctx, extensionName) {
73
73
  bus.emit("agent:register-skill", { name, description, filePath, extensionName });
74
74
  cleanups.push(() => bus.emit("agent:remove-skill", { name }));
75
75
  };
76
+ // Track dynamic-context producer registrations
77
+ const scopedRegisterContextProducer = (name, producer) => {
78
+ const dispose = ctx.registerContextProducer(name, producer);
79
+ cleanups.push(dispose);
80
+ return dispose;
81
+ };
76
82
  // Track tool registrations — extension name captured in scope
77
83
  const scopedRegisterTool = (tool) => {
78
84
  bus.emit("agent:register-tool", { tool, extensionName });
@@ -93,6 +99,7 @@ function createScopedContext(ctx, extensionName) {
93
99
  removeInstruction: ctx.removeInstruction,
94
100
  registerSkill: scopedRegisterSkill,
95
101
  removeSkill: ctx.removeSkill,
102
+ registerContextProducer: scopedRegisterContextProducer,
96
103
  registerTool: scopedRegisterTool,
97
104
  unregisterTool: ctx.unregisterTool,
98
105
  registerCommand: scopedRegisterCommand,
@@ -67,7 +67,6 @@ export default function agentBackend(ctx) {
67
67
  // would hit a no-op stub.
68
68
  const agentLoop = new AgentLoop({
69
69
  bus,
70
- contextManager: ctx.contextManager,
71
70
  llmClient,
72
71
  handlers: { define: ctx.define, advise: ctx.advise, call: ctx.call, list: ctx.list },
73
72
  modes,
@@ -1,2 +1,2 @@
1
1
  import type { ExtensionContext } from "../types.js";
2
- export default function activate({ bus, contextManager }: ExtensionContext): void;
2
+ export default function activate(ctx: ExtensionContext): void;
@@ -7,8 +7,8 @@
7
7
  */
8
8
  import * as fs from "node:fs";
9
9
  import * as path from "node:path";
10
- export default function activate({ bus, contextManager }) {
11
- bus.onPipe("autocomplete:request", (payload) => {
10
+ export default function activate(ctx) {
11
+ ctx.bus.onPipe("autocomplete:request", (payload) => {
12
12
  const atPos = payload.buffer.lastIndexOf("@");
13
13
  if (atPos < 0 || (atPos > 0 && payload.buffer[atPos - 1] !== " ")) {
14
14
  return payload;
@@ -17,7 +17,7 @@ export default function activate({ bus, contextManager }) {
17
17
  if (afterAt.includes(" ") || !/^[a-zA-Z0-9_.\/-]*$/.test(afterAt)) {
18
18
  return payload;
19
19
  }
20
- const files = listFiles(afterAt, contextManager.getCwd());
20
+ const files = listFiles(afterAt, ctx.call("cwd"));
21
21
  if (files.length === 0)
22
22
  return payload;
23
23
  return { ...payload, items: [...payload.items, ...files] };
@@ -4,6 +4,10 @@
4
4
  * These extensions ship with agent-sh and load before user extensions.
5
5
  * They receive unscoped contexts (not reloadable) and can be individually
6
6
  * disabled via the `disabledBuiltins` setting in ~/.agent-sh/settings.json.
7
+ *
8
+ * For order-critical frontend bootstrap (the PTY shell), see `src/shell/`.
9
+ * That module exposes its own `activate(ctx, opts)` entry point, loaded
10
+ * specially from `src/index.ts` rather than through this manifest.
7
11
  */
8
12
  import type { ExtensionContext } from "../types.js";
9
13
  type ActivateFn = (ctx: ExtensionContext) => void;
@@ -1,4 +1,5 @@
1
1
  export const BUILTIN_EXTENSIONS = [
2
+ { name: "shell-context", load: () => import("./shell-context.js").then(m => m.default) },
2
3
  { name: "agent-backend", load: () => import("./agent-backend.js").then(m => m.default) },
3
4
  { name: "openrouter",
4
5
  when: () => !!process.env.OPENROUTER_API_KEY,
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Tracks PTY commands and cwd, spills long outputs, contributes the
3
+ * `<shell_events>` per-query envelope. Frontends without a PTY skip this
4
+ * built-in and the agent runs cwd-aware via core's process.cwd() default.
5
+ */
6
+ import type { ExtensionContext } from "../types.js";
7
+ export default function activate(ctx: ExtensionContext): void;