mu-core 0.8.0 → 0.10.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mu-core",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "Agent loop orchestration core: types, plugin SDK, channels, sessions",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Focused tests for `runAgent`. Most coverage lives in higher layers
3
+ * (session, mu-agents subagent, integration) but we keep a few pinpoint
4
+ * cases here to lock down behaviour that's easy to break:
5
+ *
6
+ * - `display.llmHidden` strips messages from the network payload but
7
+ * keeps them in the streamed transcript surface (regression target
8
+ * for the `@`-mention dispatch flow that injects a UI-only subagent
9
+ * header alongside a real synthetic tool flow).
10
+ */
11
+ import { describe, expect, it } from 'bun:test';
12
+ import { runAgent } from './agent';
13
+ import { createProviderRegistry } from './provider/registry';
14
+ import { PluginRegistry } from './registry';
15
+ import type { ChatMessage, ProviderConfig, StreamChunk } from './types/llm';
16
+
17
+ interface CapturedCall {
18
+ messages: ChatMessage[];
19
+ }
20
+
21
+ function fakeRegistry(captured: CapturedCall[]): PluginRegistry {
22
+ const providers = createProviderRegistry();
23
+ providers.register({
24
+ id: 'openai',
25
+ async *streamChat(messages: ChatMessage[]): AsyncIterable<StreamChunk> {
26
+ // Snapshot the exact message list the provider receives so the test
27
+ // can assert on what `streamTurn` actually sent over the wire.
28
+ captured.push({ messages: messages.map((m) => ({ ...m })) });
29
+ yield { type: 'content', text: 'ok' };
30
+ },
31
+ async listModels() {
32
+ return [];
33
+ },
34
+ });
35
+ return new PluginRegistry({ cwd: '/tmp', config: {}, providers });
36
+ }
37
+
38
+ const CFG: ProviderConfig = { providerId: 'openai' };
39
+
40
+ describe('runAgent — display.llmHidden filter', () => {
41
+ it('strips llmHidden messages from the provider payload', async () => {
42
+ const captured: CapturedCall[] = [];
43
+ const registry = fakeRegistry(captured);
44
+
45
+ const messages: ChatMessage[] = [
46
+ { role: 'system', content: 'sys' },
47
+ // UI-only marker — must be filtered before the network call.
48
+ {
49
+ role: 'assistant',
50
+ content: 'phantom header',
51
+ display: { llmHidden: true },
52
+ },
53
+ { role: 'user', content: 'hi' },
54
+ ];
55
+
56
+ for await (const _ of runAgent(messages, CFG, 'm', new AbortController().signal, registry)) {
57
+ // drain
58
+ }
59
+
60
+ expect(captured.length).toBe(1);
61
+ const sent = captured[0].messages;
62
+ // Only system + user reach the provider.
63
+ expect(sent.length).toBe(2);
64
+ expect(sent[0].role).toBe('system');
65
+ expect(sent[1].role).toBe('user');
66
+ expect(sent.find((m) => m.content === 'phantom header')).toBeUndefined();
67
+ });
68
+
69
+ it('keeps non-llmHidden messages even when `display.hidden` is set', async () => {
70
+ // Inverse flag: `display.hidden` is UI-only suppression and must NOT
71
+ // affect the LLM payload. This test pins that the two flags do not
72
+ // accidentally collapse into the same filter.
73
+ const captured: CapturedCall[] = [];
74
+ const registry = fakeRegistry(captured);
75
+
76
+ const messages: ChatMessage[] = [
77
+ { role: 'system', content: 'sys' },
78
+ { role: 'user', content: 'silent reminder', display: { hidden: true } },
79
+ { role: 'user', content: 'hi' },
80
+ ];
81
+
82
+ for await (const _ of runAgent(messages, CFG, 'm', new AbortController().signal, registry)) {
83
+ // drain
84
+ }
85
+
86
+ expect(captured.length).toBe(1);
87
+ const sent = captured[0].messages;
88
+ expect(sent.length).toBe(3);
89
+ expect(sent.find((m) => m.content === 'silent reminder')).toBeDefined();
90
+ });
91
+ });
package/src/agent.ts CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  runAfterToolExecHook,
5
5
  runBeforeLlmHooks,
6
6
  runBeforeToolExecHook,
7
+ runDecorateMessageHooks,
7
8
  } from './hooks';
8
9
  import type { AgentEvent, PluginTool, ToolResult, TurnResult } from './plugin';
9
10
  import type { PluginRegistry } from './registry';
@@ -90,9 +91,15 @@ async function* streamTurn(
90
91
 
91
92
  const hooks = registry.getHooks();
92
93
  const hookedMessages = await runBeforeLlmHooks(hooks, messages, config);
94
+ // `display.llmHidden` keeps a message in the on-screen transcript but
95
+ // strips it from the LLM payload. Applied AFTER `beforeLlmHooks` so plugin
96
+ // hooks still see the full transcript if they want it; this is the very
97
+ // last filter before the network call. Inverse of `display.hidden`, which
98
+ // hides from UI but keeps in the LLM payload.
99
+ const llmMessages = hookedMessages.filter((m) => !m.display?.llmHidden);
93
100
  const toolDefinitions = toolDefs.map((t) => t.definition);
94
101
 
95
- for await (const chunk of streamChatViaRegistry(registry, hookedMessages, config, model, {
102
+ for await (const chunk of streamChatViaRegistry(registry, llmMessages, config, model, {
96
103
  signal,
97
104
  tools: toolDefinitions,
98
105
  onUsage: (u) => {
@@ -129,24 +136,24 @@ async function executeOneToolCall(
129
136
 
130
137
  if ('blocked' in hookOutcome) {
131
138
  const content = await runAfterToolExecHook(hooks, tc, hookOutcome.content);
132
- return {
139
+ return runDecorateMessageHooks(hooks, {
133
140
  role: 'tool',
134
141
  content,
135
142
  toolCallId: tc.id,
136
143
  toolResult: { name: tc.function.name, content, error: hookOutcome.error ?? true },
137
144
  toolCallArgs: { [tc.function.name]: tc.function.arguments },
138
- };
145
+ });
139
146
  }
140
147
 
141
148
  const result = await executeTool(hookOutcome, tools, signal);
142
149
  const content = await runAfterToolExecHook(hooks, hookOutcome, result.content);
143
- return {
150
+ return runDecorateMessageHooks(hooks, {
144
151
  role: 'tool',
145
152
  content,
146
153
  toolCallId: result.tool_call_id,
147
154
  toolResult: { name: result.name, content, error: result.error },
148
155
  toolCallArgs: { [result.name]: tc.function.arguments },
149
- };
156
+ });
150
157
  }
151
158
 
152
159
  async function* executeToolCalls(
@@ -220,14 +227,16 @@ export async function* runAgent(
220
227
  }
221
228
 
222
229
  const reasoningField = reasoning || undefined;
223
- const assistant: ChatMessage = { role: 'assistant', content, reasoning: reasoningField };
230
+ const assistantBase: ChatMessage = { role: 'assistant', content, reasoning: reasoningField };
224
231
  if (toolCalls.length === 0) {
232
+ const assistant = await runDecorateMessageHooks(registry.getHooks(), assistantBase);
225
233
  current = [...current, assistant];
226
234
  yield { type: 'messages', messages: current };
227
235
  return;
228
236
  }
229
237
 
230
- current = [...current, { ...assistant, toolCalls }];
238
+ const assistantWithCalls = await runDecorateMessageHooks(registry.getHooks(), { ...assistantBase, toolCalls });
239
+ current = [...current, assistantWithCalls];
231
240
  yield { type: 'messages', messages: current };
232
241
  current = yield* executeToolCalls(toolCalls, current, signal, registry, tools);
233
242
  yield { type: 'turn_end' };
package/src/hooks.test.ts CHANGED
@@ -73,4 +73,33 @@ describe('runTransformUserInputHooks', () => {
73
73
  expect(result.kind).toBe('intercept');
74
74
  expect(secondCalled).toBe(false);
75
75
  });
76
+
77
+ it('propagates continue to the caller', async () => {
78
+ // Regression: the composer used to silently drop `continue`, falling
79
+ // back to `pass`. That made the host re-push the user message on top
80
+ // of the one a plugin (mu-agents @-mention dispatch) had already
81
+ // appended, producing a duplicate user bubble in the transcript.
82
+ const hooks: LifecycleHooks[] = [{ transformUserInput: () => ({ kind: 'continue' }) }];
83
+ const result = await runTransformUserInputHooks(hooks, 'X');
84
+ expect(result.kind).toBe('continue');
85
+ });
86
+
87
+ it('continue short-circuits the chain', async () => {
88
+ // Once a plugin has appended the user message itself, downstream
89
+ // hooks can't safely transform absent text — same chain-termination
90
+ // semantics as `intercept`.
91
+ let secondCalled = false;
92
+ const hooks: LifecycleHooks[] = [
93
+ { transformUserInput: () => ({ kind: 'continue' }) },
94
+ {
95
+ transformUserInput: () => {
96
+ secondCalled = true;
97
+ return { kind: 'transform', text: 'should-not-apply' };
98
+ },
99
+ },
100
+ ];
101
+ const result = await runTransformUserInputHooks(hooks, 'X');
102
+ expect(result.kind).toBe('continue');
103
+ expect(secondCalled).toBe(false);
104
+ });
76
105
  });
package/src/hooks.ts CHANGED
@@ -58,6 +58,22 @@ export async function runAfterToolExecHook(
58
58
  return current;
59
59
  }
60
60
 
61
+ /**
62
+ * Pipe a freshly built `ChatMessage` through every `decorateMessage` hook in
63
+ * order. Each hook may return a (possibly mutated) message; later hooks see
64
+ * the result of the previous one. Used to stamp display hints (agent badge,
65
+ * color) without coupling the host to any specific plugin.
66
+ */
67
+ export async function runDecorateMessageHooks(hooks: LifecycleHooks[], msg: ChatMessage): Promise<ChatMessage> {
68
+ let current = msg;
69
+ for (const hook of hooks) {
70
+ if (hook.decorateMessage) {
71
+ current = await hook.decorateMessage(current);
72
+ }
73
+ }
74
+ return current;
75
+ }
76
+
61
77
  export async function runAfterAgentRunHooks(hooks: LifecycleHooks[], reason: AgentEndReason): Promise<void> {
62
78
  for (const hook of hooks) {
63
79
  if (hook.afterAgentRun) {
@@ -69,7 +85,11 @@ export async function runAfterAgentRunHooks(hooks: LifecycleHooks[], reason: Age
69
85
  /**
70
86
  * Compose every `transformUserInput` hook. Earlier hooks see the raw text;
71
87
  * each subsequent hook sees the (possibly rewritten) text emitted by the
72
- * previous one. The first `intercept` short-circuits the chain.
88
+ * previous one. The first `intercept` or `continue` short-circuits the chain
89
+ * — once a plugin has either suppressed the input or appended the user
90
+ * message itself, downstream hooks can't safely keep transforming absent
91
+ * text, and the host needs to see the terminating signal verbatim so it
92
+ * skips its own user-message push (see `useOnSend`).
73
93
  */
74
94
  export async function runTransformUserInputHooks(hooks: LifecycleHooks[], text: string): Promise<UserInputTransform> {
75
95
  let current: UserInputTransform = { kind: 'pass' };
@@ -80,6 +100,9 @@ export async function runTransformUserInputHooks(hooks: LifecycleHooks[], text:
80
100
  if (next.kind === 'intercept') {
81
101
  return next;
82
102
  }
103
+ if (next.kind === 'continue') {
104
+ return next;
105
+ }
83
106
  if (next.kind === 'transform') {
84
107
  working = next.text;
85
108
  current = next;
package/src/index.ts CHANGED
@@ -3,7 +3,7 @@ export { createActivityBus } from './activity';
3
3
  export { runAgent } from './agent';
4
4
  export type { Channel, ChannelRegistry, ChannelResponder, InboundKind, InboundMessage, ResponseMode } from './channel';
5
5
  export { createChannelRegistry } from './channel';
6
- export { runTransformUserInputHooks } from './hooks';
6
+ export { runDecorateMessageHooks, runTransformUserInputHooks } from './hooks';
7
7
  export type { MuConfigShape, MuHandle, StartMuOptions } from './host/index';
8
8
  export { startMu } from './host/index';
9
9
  export type {
@@ -13,6 +13,7 @@ export type {
13
13
  AgentSourceRegistry,
14
14
  BeforeToolExecResult,
15
15
  CommandContext,
16
+ InputInfoSegment,
16
17
  LifecycleHooks,
17
18
  MentionCompletion,
18
19
  MentionProvider,
package/src/plugin.ts CHANGED
@@ -59,6 +59,12 @@ export interface MentionCompletion {
59
59
  label?: string;
60
60
  /** Secondary text shown dimly in the picker. */
61
61
  description?: string;
62
+ /**
63
+ * Optional grouping label rendered as a section header in the picker
64
+ * (e.g. "agents", "files"). When unset, completions are rendered without
65
+ * a group header. The host hides the header when only one group is shown.
66
+ */
67
+ category?: string;
62
68
  }
63
69
 
64
70
  export type MentionProvider = (partial: string) => MentionCompletion[] | Promise<MentionCompletion[]>;
@@ -96,6 +102,12 @@ export interface PluginRegistryView {
96
102
  getHooks: () => LifecycleHooks[];
97
103
  getSystemPrompts: () => Promise<string[]>;
98
104
  applySystemPromptTransforms: (prompt: string) => Promise<string>;
105
+ /**
106
+ * Provider registry handle (or `undefined` if the host didn't supply one).
107
+ * Exposed so plugins that re-issue LLM calls (e.g. the mu-agents subagent
108
+ * loop) can resolve the configured provider exactly like `runAgent` does.
109
+ */
110
+ getProviders: () => ProviderRegistry | undefined;
99
111
  }
100
112
 
101
113
  export interface PluginContext extends PluginExtras {
@@ -119,6 +131,13 @@ export interface PluginContext extends PluginExtras {
119
131
  * polling-based `Plugin.statusLine()` getter. Pass `[]` to clear.
120
132
  */
121
133
  setStatusLine?: (segments: StatusSegment[]) => void;
134
+ /**
135
+ * Push info chips into the host's input footer (e.g. "Coding" agent
136
+ * label). Replaces the segments previously pushed by *this* plugin. Pass
137
+ * `[]` to clear. Hosts that don't render an input footer are free to
138
+ * ignore the call.
139
+ */
140
+ setInputInfo?: (segments: InputInfoSegment[]) => void;
122
141
  /**
123
142
  * Host-provided graceful shutdown hook. When supplied, plugins should prefer
124
143
  * this over `process.exit(...)` so the host can deactivate plugins and restore
@@ -242,8 +261,18 @@ export type BeforeToolExecResult = ToolCall | ToolBlock;
242
261
  * - `intercept` — suppress the input entirely; the host should not call the
243
262
  * LLM. Plugins typically pair this with `MessageBus.append` to surface a
244
263
  * reply or status entry.
264
+ * - `continue` — the hook has appended the user message itself (e.g. via
265
+ * `MessageBus.append` so it shows up live in the transcript). The host
266
+ * should NOT push another user message but should still run a turn:
267
+ * drain the injectNext queue and stream the LLM. Used by the subagent
268
+ * `@`-mention dispatch path so the user's message lands first, the
269
+ * subagent runs live, and the parent agent then takes a real turn.
245
270
  */
246
- export type UserInputTransform = { kind: 'pass' } | { kind: 'transform'; text: string } | { kind: 'intercept' };
271
+ export type UserInputTransform =
272
+ | { kind: 'pass' }
273
+ | { kind: 'transform'; text: string }
274
+ | { kind: 'intercept' }
275
+ | { kind: 'continue' };
247
276
 
248
277
  export interface LifecycleHooks {
249
278
  beforeLlmCall?: (messages: ChatMessage[], config: ProviderConfig) => ChatMessage[] | Promise<ChatMessage[]>;
@@ -276,6 +305,14 @@ export interface LifecycleHooks {
276
305
  * cleanup; per-turn cleanup belongs in `afterLlmCall`.
277
306
  */
278
307
  afterAgentRun?: (reason: AgentEndReason) => void | Promise<void>;
308
+ /**
309
+ * Decorate a freshly built `ChatMessage` (user / assistant / tool) before
310
+ * it's appended to the transcript. Plugins typically use this to stamp
311
+ * `display.badge` / `display.color` (e.g. with the active agent name +
312
+ * color) or augment `meta`. Hooks compose left-to-right; later hooks see
313
+ * the prior hook's output. Should not change `role` or `content`.
314
+ */
315
+ decorateMessage?: (msg: ChatMessage) => ChatMessage | Promise<ChatMessage>;
279
316
  }
280
317
 
281
318
  export interface CommandContext {
@@ -315,6 +352,19 @@ export interface StatusSegment {
315
352
  dim?: boolean;
316
353
  }
317
354
 
355
+ /**
356
+ * Info chip a plugin pushes into the input footer (e.g. active agent name).
357
+ * Aggregated across plugins by `PluginRegistry.getInputInfoSegments()` and
358
+ * surfaced to the host's input UI in registration order.
359
+ */
360
+ export interface InputInfoSegment {
361
+ /** Stable key — used by the renderer for list reconciliation. */
362
+ key: string;
363
+ text: string;
364
+ color?: string;
365
+ bold?: boolean;
366
+ }
367
+
318
368
  export interface Plugin {
319
369
  name: string;
320
370
  version?: string;
package/src/registry.ts CHANGED
@@ -3,6 +3,7 @@ import type { ChannelRegistry } from './channel';
3
3
  import type {
4
4
  AgentLoopStrategy,
5
5
  AgentSourceRegistry,
6
+ InputInfoSegment,
6
7
  LifecycleHooks,
7
8
  MentionProvider,
8
9
  MessageBus,
@@ -73,6 +74,8 @@ export class PluginRegistry {
73
74
  private context: PluginContext;
74
75
  private statusSegmentsByPlugin: Map<string, StatusSegment[]> = new Map();
75
76
  private statusListeners: Set<StatusListener> = new Set();
77
+ private inputInfoByPlugin: Map<string, InputInfoSegment[]> = new Map();
78
+ private inputInfoListeners: Set<StatusListener> = new Set();
76
79
  private renderers: RendererEntry[] = [];
77
80
  private shortcuts: ShortcutEntry[] = [];
78
81
  private mentions: MentionEntry[] = [];
@@ -120,6 +123,9 @@ export class PluginRegistry {
120
123
  if (this.statusSegmentsByPlugin.delete(name)) {
121
124
  this.emitStatus();
122
125
  }
126
+ if (this.inputInfoByPlugin.delete(name)) {
127
+ this.emitInputInfo();
128
+ }
123
129
  this.dropPluginRegistrations(name);
124
130
  }
125
131
 
@@ -246,6 +252,24 @@ export class PluginRegistry {
246
252
  };
247
253
  }
248
254
 
255
+ /** Aggregate of every plugin's most recently pushed input-info segments, in registration order. */
256
+ getInputInfoSegments(): InputInfoSegment[] {
257
+ const segments: InputInfoSegment[] = [];
258
+ for (const plugin of this.plugins.values()) {
259
+ const pluginSegments = this.inputInfoByPlugin.get(plugin.name);
260
+ if (pluginSegments?.length) segments.push(...pluginSegments);
261
+ }
262
+ return segments;
263
+ }
264
+
265
+ /** Subscribe to input-info segment changes. Returns an unsubscribe fn. */
266
+ onInputInfoChange(listener: StatusListener): () => void {
267
+ this.inputInfoListeners.add(listener);
268
+ return () => {
269
+ this.inputInfoListeners.delete(listener);
270
+ };
271
+ }
272
+
249
273
  getAgentLoop(): AgentLoopStrategy | undefined {
250
274
  for (const plugin of this.plugins.values()) {
251
275
  if (plugin.agentLoop) {
@@ -319,8 +343,10 @@ export class PluginRegistry {
319
343
  getHooks: () => this.getHooks(),
320
344
  getSystemPrompts: () => this.getSystemPrompts(),
321
345
  applySystemPromptTransforms: (prompt) => this.applySystemPromptTransforms(prompt),
346
+ getProviders: () => this.getProviders(),
322
347
  },
323
348
  setStatusLine: (segments) => this.setStatusLine(pluginName, segments),
349
+ setInputInfo: (segments) => this.setInputInfo(pluginName, segments),
324
350
  registerMessageRenderer: (customType, renderer) => this.addRenderer(pluginName, customType, renderer),
325
351
  registerShortcut: (key, handler) => this.addShortcut(pluginName, key, handler),
326
352
  registerMentionProvider: (trigger, provider) => this.addMention(pluginName, trigger, provider),
@@ -349,12 +375,30 @@ export class PluginRegistry {
349
375
  this.emitStatus();
350
376
  }
351
377
 
378
+ private setInputInfo(pluginName: string, segments: InputInfoSegment[]): void {
379
+ if (segments.length === 0) {
380
+ const removed = this.inputInfoByPlugin.delete(pluginName);
381
+ if (removed) this.emitInputInfo();
382
+ return;
383
+ }
384
+ const prev = this.inputInfoByPlugin.get(pluginName);
385
+ if (prev && inputInfoEqual(prev, segments)) {
386
+ return;
387
+ }
388
+ this.inputInfoByPlugin.set(pluginName, segments);
389
+ this.emitInputInfo();
390
+ }
391
+
352
392
  private emitStatus(): void {
353
393
  for (const listener of this.statusListeners) {
354
394
  listener();
355
395
  }
356
396
  }
357
397
 
398
+ private emitInputInfo(): void {
399
+ for (const listener of this.inputInfoListeners) listener();
400
+ }
401
+
358
402
  private addRenderer(plugin: string, customType: string, renderer: MessageRenderer): () => void {
359
403
  const entry: RendererEntry = { plugin, customType, renderer };
360
404
  this.renderers.push(entry);
@@ -428,3 +472,13 @@ function segmentsEqual(a: StatusSegment[], b: StatusSegment[]): boolean {
428
472
  }
429
473
  return true;
430
474
  }
475
+
476
+ function inputInfoEqual(a: InputInfoSegment[], b: InputInfoSegment[]): boolean {
477
+ if (a.length !== b.length) return false;
478
+ for (let i = 0; i < a.length; i++) {
479
+ if (a[i].key !== b[i].key || a[i].text !== b[i].text || a[i].color !== b[i].color || a[i].bold !== b[i].bold) {
480
+ return false;
481
+ }
482
+ }
483
+ return true;
484
+ }
package/src/session.ts CHANGED
@@ -22,8 +22,15 @@ export type SessionEvent =
22
22
  | { type: 'error'; message: string };
23
23
 
24
24
  export interface RunTurnOptions {
25
- /** Pre-built user message to append before running the agent loop. */
26
- userMessage: ChatMessage;
25
+ /**
26
+ * Pre-built user message to append before running the agent loop.
27
+ * Optional: when a plugin's `transformUserInput` returns `'continue'`
28
+ * the hook has already appended its own user message via
29
+ * `MessageBus.append`, and the host calls `runTurn` without a
30
+ * `userMessage` to drain the injectNext queue and stream the LLM
31
+ * without pushing a duplicate.
32
+ */
33
+ userMessage?: ChatMessage;
27
34
  /** Override config for this single turn (e.g. fresh model id). */
28
35
  config?: ProviderConfig;
29
36
  /** Override model for this single turn. */
@@ -159,8 +166,14 @@ class SessionImpl implements Session {
159
166
  } else if (e.type === 'usage') {
160
167
  this.emit({ type: 'usage', totalTokens: e.totalTokens, cachedTokens: e.cachedTokens ?? 0 });
161
168
  } else if (e.type === 'turn_end') {
169
+ // Clear the locally-tracked partial buffers AND notify subscribers,
170
+ // otherwise the host's `stream` state still holds the previous step's
171
+ // reasoning/content between agent loop iterations — visible as a
172
+ // stale "thinking…" block lingering after a tool call until the next
173
+ // step's first `content`/`reasoning` chunk overwrites it.
162
174
  partialText = '';
163
175
  partialReasoning = '';
176
+ this.emit({ type: 'stream_partial', text: '', reasoning: '' });
164
177
  }
165
178
  }
166
179
  return final;
@@ -175,7 +188,10 @@ class SessionImpl implements Session {
175
188
  throw new Error(`Session "${this.id}" already running a turn. Call abort() first or wait for completion.`);
176
189
  }
177
190
  if (options.baseMessages) this.messages = options.baseMessages.slice();
178
- this.messages.push(options.userMessage);
191
+ // Skip the push when the caller didn't supply a userMessage — that
192
+ // happens when a `transformUserInput` hook returned `'continue'` and
193
+ // already appended the user's message itself (see `UserInputTransform`).
194
+ if (options.userMessage) this.messages.push(options.userMessage);
179
195
  if (this.queue.length) {
180
196
  this.messages.push(...this.queue);
181
197
  this.queue = [];
package/src/types/llm.ts CHANGED
@@ -62,12 +62,17 @@ export interface ToolResultInfo {
62
62
  * - `badge` is shown in a small box before the body (e.g. agent name)
63
63
  * - `hidden` keeps the message in the transcript (sent to the LLM) but skips
64
64
  * its on-screen rendering — useful for system reminders.
65
+ * - `llmHidden` is the inverse: keep the message in the on-screen transcript
66
+ * but strip it from the LLM payload right before the network call. Useful
67
+ * for UI-only markers (subagent dispatch headers, status pings) that
68
+ * plugins want users to see but the model shouldn't read as conversation.
65
69
  */
66
70
  export interface MessageDisplay {
67
71
  color?: string;
68
72
  prefix?: string;
69
73
  badge?: string;
70
74
  hidden?: boolean;
75
+ llmHidden?: boolean;
71
76
  }
72
77
 
73
78
  export interface ChatMessage {
@@ -104,4 +109,12 @@ export interface StreamOptions {
104
109
 
105
110
  export interface ApiModel {
106
111
  id: string;
112
+ /**
113
+ * Maximum input + output token window the provider advertises for this
114
+ * model. OpenAI itself doesn't expose this on `/models`; compat servers
115
+ * (llama.cpp, LM Studio, vLLM, Ollama's openai shim, ...) often do under
116
+ * names like `context_length`, `max_context_length`, or
117
+ * `max_position_embeddings`. Omitted when the provider doesn't report it.
118
+ */
119
+ contextLimit?: number;
107
120
  }