@selesai/code 0.11.0 → 0.13.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 (39) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/cli/startup-ui.js +2 -1
  3. package/dist/core/agent-session.d.ts +12 -1
  4. package/dist/core/agent-session.js +77 -9
  5. package/dist/core/compaction/branch-summarization.js +4 -3
  6. package/dist/core/compaction/compaction.d.ts +5 -0
  7. package/dist/core/compaction/compaction.js +19 -4
  8. package/dist/core/extensions/index.d.ts +1 -1
  9. package/dist/core/extensions/runner.d.ts +5 -0
  10. package/dist/core/extensions/runner.js +48 -1
  11. package/dist/core/extensions/types.d.ts +18 -1
  12. package/dist/core/llama/client.d.ts +6 -0
  13. package/dist/core/llama/client.js +7 -0
  14. package/dist/core/llama/provider.d.ts +3 -1
  15. package/dist/core/llama/provider.js +28 -6
  16. package/dist/core/session-manager.js +4 -2
  17. package/dist/core/settings-manager.d.ts +8 -1
  18. package/dist/core/settings-manager.js +17 -0
  19. package/dist/core/usage-totals.d.ts +2 -0
  20. package/dist/core/usage-totals.js +10 -0
  21. package/dist/defaults/models.json +32 -9
  22. package/dist/extensions/pi-zentui/extensions/zentui/format.ts +2 -1
  23. package/dist/extensions/pi-zentui/test/format.test.ts +15 -1
  24. package/dist/extensions/tokenin-onboarding.ts +112 -2
  25. package/dist/index.d.ts +2 -1
  26. package/dist/index.js +1 -0
  27. package/dist/main.js +2 -0
  28. package/dist/modes/interactive/components/footer.js +2 -2
  29. package/dist/modes/interactive/components/settings-selector.d.ts +2 -0
  30. package/dist/modes/interactive/components/settings-selector.js +10 -0
  31. package/dist/modes/interactive/interactive-mode.d.ts +4 -0
  32. package/dist/modes/interactive/interactive-mode.js +59 -43
  33. package/dist/modes/rpc/rpc-client.d.ts +7 -0
  34. package/dist/modes/rpc/rpc-client.js +7 -0
  35. package/dist/modes/rpc/rpc-mode.js +3 -0
  36. package/dist/modes/rpc/rpc-types.d.ts +12 -0
  37. package/dist/utils/shell.js +6 -4
  38. package/docs/settings.md +4 -0
  39. package/package.json +4 -4
package/CHANGELOG.md CHANGED
@@ -2,6 +2,32 @@
2
2
 
3
3
  All notable changes to `@selesai/code` will be documented in this file.
4
4
 
5
+ ## [0.13.0] - 2026-08-29
6
+
7
+ ### Fixed
8
+ - **Sub-cent cost display.** Cost labels no longer round small per-call costs to `$0.000`. The footer, zentui footer, and usage popup now format costs adaptively: 3 decimals at $1+, 4 decimals at $0.01+, and 6 decimals below, so gateway-reconciled costs like `$0.000336` are visible.
9
+
10
+ ## [0.12.0] - 2026-08-29
11
+
12
+ ### Added
13
+ - **`/tokenin usage` subcommand.** The `tokenin-onboarding` extension now queries the LiteLLM `/key/info` endpoint with the active Token-In token and shows a boxed spend/budget/remaining/reset display with a progress bar. Run `/tokenin usage` to check quota without leaving the session.
14
+ - **Terminal capability overrides.** New `terminal.images` (`"kitty"`, `"iterm2"`, `"auto"`, or `false`), `terminal.trueColor`, and `terminal.hyperlinks` settings let you pin terminal capabilities instead of relying on auto-detection.
15
+ - **`fullscreenCopyOnSelect` setting.** New setting (default `true`) and `/settings` toggle: automatically copy selected text in fullscreen mode; disable to copy selections with Ctrl+X.
16
+ - **`ui_prompt_start` / `ui_prompt_end` extension events.** Extensions can now observe when the agent starts and stops waiting on a blocking user-facing UI prompt (`select`, `confirm`, `input`, `editor`, `custom`).
17
+ - **llama.cpp router autoload.** Unloaded preset models are now selectable when the llama.cpp router has `models_autoload` enabled, so they load on first use instead of being hidden.
18
+ - **RPC `clear_queue` command.** RPC clients can clear the pending steering/follow-up queue and get back the dropped items.
19
+
20
+ ### Changed
21
+ - **Custom message ordering during streaming.** Context-only custom messages queued while the agent is streaming are now flushed at turn end (after tool results are in) instead of being appended between a tool call and its result, which some providers reject on replay.
22
+ - **Auto-compaction before the next assistant response.** The session now checks the context threshold before preparing the next turn and compacts when needed, so long conversations stay within the model window.
23
+ - **Persisted default model joins scoped model lists.** When a scoped model list (`--models`) is active, the persisted default model is added to the scope and to `enabledModels` so it stays reachable.
24
+ - **Model catalog updates.** Celestial, DeepSeek, and GPT models now declare `compat.requiresReasoningContentOnAssistantMessages` where required.
25
+ - **Windows process cleanup.** `taskkill` is invoked via the trusted System32 path instead of relying on `PATH`, and failed spawns no longer crash Node.
26
+
27
+ ### Fixed
28
+ - **Footer provider count after model switch.** The interactive footer's available-provider count now refreshes immediately after switching models in the model selector.
29
+ - **Summarization length-stop guard.** A summarization response that hits the token cap (partial text) is now treated as a failure and never persisted as a session checkpoint.
30
+
5
31
  ## [0.11.0] - 2026-08-29
6
32
 
7
33
  ### Added
@@ -1,4 +1,4 @@
1
- import { ProcessTerminal, setKeybindings, TuiMainScreen } from "@earendil-works/pi-tui";
1
+ import { ProcessTerminal, setCapabilityOverrides, setKeybindings, TuiMainScreen, } from "@earendil-works/pi-tui";
2
2
  import { existsSync } from "fs";
3
3
  import { ENV_AGENT_DIR, getAgentDir, getFirstRunMarkerPath, markFirstRunComplete } from "../config.js";
4
4
  import { KeybindingsManager } from "../core/keybindings.js";
@@ -43,6 +43,7 @@ async function loadStartupThemes(settingsManager) {
43
43
  return loadThemes(resolvedPaths.themes);
44
44
  }
45
45
  export async function createStartupTui(settingsManager) {
46
+ setCapabilityOverrides(settingsManager.getTerminalCapabilityOverrides());
46
47
  setRegisteredThemes(await loadStartupThemes(settingsManager));
47
48
  const terminalTheme = detectTerminalBackgroundFromEnv().theme;
48
49
  initTheme(resolveThemeSetting(settingsManager.getThemeSetting(), terminalTheme) ?? terminalTheme);
@@ -218,6 +218,8 @@ export declare class AgentSession {
218
218
  private _followUpMessages;
219
219
  /** Messages queued to be included with the next user prompt as context ("asides"). */
220
220
  private _pendingNextTurnMessages;
221
+ /** Context-only custom messages queued during a run, flushed once the current turn's tool results are in. */
222
+ private _pendingCustomMessages;
221
223
  private _compactionAbortController;
222
224
  private _autoCompactionAbortController;
223
225
  private _overflowRecoveryAttempted;
@@ -266,6 +268,7 @@ export declare class AgentSession {
266
268
  * happens here instead of in wrappers.
267
269
  */
268
270
  private _installAgentToolHooks;
271
+ private _compactBeforeNextAssistantResponse;
269
272
  private _installAgentNextTurnRefresh;
270
273
  /** Emit an event to all listeners */
271
274
  private _emit;
@@ -412,8 +415,9 @@ export declare class AgentSession {
412
415
  /**
413
416
  * Send a custom message to the session. Creates a CustomMessageEntry.
414
417
  *
415
- * Handles three cases:
418
+ * Handles four cases:
416
419
  * - Streaming: queues message, processed when loop pulls from queue
420
+ * - Streaming + triggerTurn false: appended to state/session once the current turn ends
417
421
  * - Not streaming + triggerTurn: appends to state/session, starts new turn
418
422
  * - Not streaming + no trigger: appends to state/session, no turn
419
423
  *
@@ -425,6 +429,12 @@ export declare class AgentSession {
425
429
  triggerTurn?: boolean;
426
430
  deliverAs?: "steer" | "followUp" | "nextTurn";
427
431
  }): Promise<void>;
432
+ private _appendCustomMessage;
433
+ /**
434
+ * Append custom messages queued while the agent was running.
435
+ * Called once the current turn's tool results are in agent state and session history.
436
+ */
437
+ private _flushPendingCustomMessages;
428
438
  private _captionImagesForCurrentModel;
429
439
  /**
430
440
  * Build a small, text-only slice of the most recent conversation to give the
@@ -485,6 +495,7 @@ export declare class AgentSession {
485
495
  * @throws Error if no auth is configured for the model
486
496
  */
487
497
  setModel(model: Model<any>, options?: ModelMutationOptions): Promise<void>;
498
+ private _addPersistedDefaultToNonEmptyScope;
488
499
  /**
489
500
  * Cycle to next/previous model.
490
501
  * Uses scoped models (from --models flag) if available, otherwise all available models.
@@ -102,6 +102,8 @@ export class AgentSession {
102
102
  _followUpMessages = [];
103
103
  /** Messages queued to be included with the next user prompt as context ("asides"). */
104
104
  _pendingNextTurnMessages = [];
105
+ /** Context-only custom messages queued during a run, flushed once the current turn's tool results are in. */
106
+ _pendingCustomMessages = [];
105
107
  // Compaction state
106
108
  _compactionAbortController = undefined;
107
109
  _autoCompactionAbortController = undefined;
@@ -269,18 +271,33 @@ export class AgentSession {
269
271
  };
270
272
  };
271
273
  }
274
+ async _compactBeforeNextAssistantResponse(context) {
275
+ const model = this.model;
276
+ const settings = this.settingsManager.getCompactionSettings();
277
+ if (!model ||
278
+ model.contextWindow <= 0 ||
279
+ !shouldCompact(estimateContextTokens(context.messages).tokens, model.contextWindow, settings)) {
280
+ return context;
281
+ }
282
+ await this._runAutoCompaction("threshold", false);
283
+ return {
284
+ ...context,
285
+ messages: this.agent.state.messages.slice(),
286
+ };
287
+ }
272
288
  _installAgentNextTurnRefresh() {
273
289
  const previousPrepareNextTurnWithContext = this.agent.prepareNextTurnWithContext ??
274
290
  (this.agent.prepareNextTurn
275
291
  ? async (_turn, signal) => await this.agent.prepareNextTurn?.(signal)
276
292
  : undefined);
277
293
  this.agent.prepareNextTurnWithContext = async (turn, signal) => {
278
- const previousSnapshot = await previousPrepareNextTurnWithContext?.(turn, signal);
279
- const previousContext = previousSnapshot?.context ?? turn.context;
294
+ const context = await this._compactBeforeNextAssistantResponse(turn.context);
295
+ const previousSnapshot = await previousPrepareNextTurnWithContext?.({ ...turn, context }, signal);
296
+ const nextContext = previousSnapshot?.context ?? context;
280
297
  return {
281
298
  ...previousSnapshot,
282
299
  context: {
283
- ...previousContext,
300
+ ...nextContext,
284
301
  systemPrompt: this._systemPromptOverride ?? this._baseSystemPrompt,
285
302
  tools: this.agent.state.tools.slice(),
286
303
  },
@@ -436,6 +453,14 @@ export class AgentSession {
436
453
  }
437
454
  }
438
455
  }
456
+ // A turn ends after its assistant message and every tool result has been appended,
457
+ // so this is the first point in the run where a context-only custom message can be
458
+ // inserted without landing between a tool call and its result. Flushing after the
459
+ // extension and listener dispatch above also picks up messages that turn_end
460
+ // handlers queued.
461
+ if (event.type === "turn_end") {
462
+ this._flushPendingCustomMessages();
463
+ }
439
464
  };
440
465
  _willRetryAfterAgentEnd(event) {
441
466
  const settings = this.settingsManager.getRetrySettings();
@@ -813,6 +838,7 @@ export class AgentSession {
813
838
  finally {
814
839
  this._systemPromptOverride = undefined;
815
840
  this._flushPendingBashMessages();
841
+ this._flushPendingCustomMessages();
816
842
  await this._emitAgentSettled();
817
843
  }
818
844
  }
@@ -908,8 +934,9 @@ export class AgentSession {
908
934
  preflightResult?.(true);
909
935
  return;
910
936
  }
911
- // Flush any pending bash messages before the new prompt
937
+ // Flush any pending bash and custom messages before the new prompt
912
938
  this._flushPendingBashMessages();
939
+ this._flushPendingCustomMessages();
913
940
  // Validate model
914
941
  if (!this.model) {
915
942
  throw new Error(formatNoModelSelectedMessage());
@@ -1138,8 +1165,9 @@ export class AgentSession {
1138
1165
  /**
1139
1166
  * Send a custom message to the session. Creates a CustomMessageEntry.
1140
1167
  *
1141
- * Handles three cases:
1168
+ * Handles four cases:
1142
1169
  * - Streaming: queues message, processed when loop pulls from queue
1170
+ * - Streaming + triggerTurn false: appended to state/session once the current turn ends
1143
1171
  * - Not streaming + triggerTurn: appends to state/session, starts new turn
1144
1172
  * - Not streaming + no trigger: appends to state/session, no turn
1145
1173
  *
@@ -1171,11 +1199,34 @@ export class AgentSession {
1171
1199
  else if (options?.triggerTurn) {
1172
1200
  await this._runAgentPrompt(appMessage);
1173
1201
  }
1202
+ else if (this.isStreaming) {
1203
+ // Appending now would put the message between an assistant tool call and its
1204
+ // result, which providers that validate message order reject on replay. Defer
1205
+ // to the end of the turn. Nothing is emitted yet: message events must not
1206
+ // describe messages the session tree does not contain.
1207
+ this._pendingCustomMessages.push(appMessage);
1208
+ }
1174
1209
  else {
1175
- this.agent.state.messages.push(appMessage);
1176
- this.sessionManager.appendCustomMessageEntry(message.customType, message.content, message.display, message.details);
1177
- this._emit({ type: "message_start", message: appMessage });
1178
- this._emit({ type: "message_end", message: appMessage });
1210
+ this._appendCustomMessage(appMessage);
1211
+ }
1212
+ }
1213
+ _appendCustomMessage(appMessage) {
1214
+ this.agent.state.messages.push(appMessage);
1215
+ this.sessionManager.appendCustomMessageEntry(appMessage.customType, appMessage.content, appMessage.display, appMessage.details);
1216
+ this._emit({ type: "message_start", message: appMessage });
1217
+ this._emit({ type: "message_end", message: appMessage });
1218
+ }
1219
+ /**
1220
+ * Append custom messages queued while the agent was running.
1221
+ * Called once the current turn's tool results are in agent state and session history.
1222
+ */
1223
+ _flushPendingCustomMessages() {
1224
+ if (this._pendingCustomMessages.length === 0)
1225
+ return;
1226
+ const pending = this._pendingCustomMessages;
1227
+ this._pendingCustomMessages = [];
1228
+ for (const appMessage of pending) {
1229
+ this._appendCustomMessage(appMessage);
1179
1230
  }
1180
1231
  }
1181
1232
  /**
@@ -1448,10 +1499,25 @@ export class AgentSession {
1448
1499
  this.sessionManager.appendModelChange(model.provider, model.id);
1449
1500
  if (options.persist) {
1450
1501
  this.settingsManager.setDefaultModelAndProvider(model.provider, model.id);
1502
+ this._addPersistedDefaultToNonEmptyScope(model);
1451
1503
  }
1452
1504
  this.setThinkingLevel(thinkingLevel);
1453
1505
  await this._emitModelSelect(model, previousModel, "set");
1454
1506
  }
1507
+ _addPersistedDefaultToNonEmptyScope(model) {
1508
+ if (this._scopedModels.length === 0)
1509
+ return;
1510
+ if (this._scopedModels.some((scoped) => modelsAreEqual(scoped.model, model)))
1511
+ return;
1512
+ this._scopedModels = [...this._scopedModels, { model }];
1513
+ const enabledModels = this.settingsManager.getEnabledModels();
1514
+ if (!enabledModels?.length)
1515
+ return;
1516
+ const modelReference = `${model.provider}/${model.id}`;
1517
+ if (enabledModels.some((pattern) => pattern.toLowerCase() === modelReference.toLowerCase()))
1518
+ return;
1519
+ this.settingsManager.setEnabledModels([...enabledModels, modelReference]);
1520
+ }
1455
1521
  /**
1456
1522
  * Cycle to next/previous model.
1457
1523
  * Uses scoped models (from --models flag) if available, otherwise all available models.
@@ -1485,6 +1551,7 @@ export class AgentSession {
1485
1551
  this.sessionManager.appendModelChange(next.model.provider, next.model.id);
1486
1552
  if (options.persist) {
1487
1553
  this.settingsManager.setDefaultModelAndProvider(next.model.provider, next.model.id);
1554
+ this._addPersistedDefaultToNonEmptyScope(next.model);
1488
1555
  }
1489
1556
  // Apply thinking level.
1490
1557
  // - Explicit scoped model thinking level overrides current session level
@@ -1510,6 +1577,7 @@ export class AgentSession {
1510
1577
  this.sessionManager.appendModelChange(nextModel.provider, nextModel.id);
1511
1578
  if (options.persist) {
1512
1579
  this.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id);
1580
+ this._addPersistedDefaultToNonEmptyScope(nextModel);
1513
1581
  }
1514
1582
  // Re-clamp thinking level for new model's capabilities
1515
1583
  this.setThinkingLevel(thinkingLevel);
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import { contentText } from "@earendil-works/pi-ai";
8
8
  import { convertToLlm, createBranchSummaryMessage, createCompactionSummaryMessage, createCustomMessage, } from "../messages.js";
9
- import { completeSummarization, estimateTokens } from "./compaction.js";
9
+ import { completeSummarization, estimateTokens, getSummarizationFailure } from "./compaction.js";
10
10
  import { computeFileLists, createFileOps, extractFileOpsFromMessage, formatFileOperations, SUMMARIZATION_SYSTEM_PROMPT, serializeConversation, } from "./utils.js";
11
11
  // ============================================================================
12
12
  // Entry Collection
@@ -227,8 +227,9 @@ export async function generateBranchSummary(entries, options) {
227
227
  if (response.stopReason === "aborted") {
228
228
  return { aborted: true };
229
229
  }
230
- if (response.stopReason === "error") {
231
- return { error: response.errorMessage || "Summarization failed" };
230
+ const failure = getSummarizationFailure(response, "Branch summarization");
231
+ if (failure) {
232
+ return { error: failure };
232
233
  }
233
234
  let summary = contentText(response.content);
234
235
  // Prepend preamble to provide context about the branch summary
@@ -90,6 +90,11 @@ export interface CutPointResult {
90
90
  * Only considers entries between `startIndex` and `endIndex` (exclusive).
91
91
  */
92
92
  export declare function findCutPoint(entries: SessionEntry[], startIndex: number, endIndex: number, keepRecentTokens: number): CutPointResult;
93
+ /**
94
+ * Returns an error message when a summarization response cannot safely be persisted.
95
+ * A length stop contains partial text and must not become a session checkpoint.
96
+ */
97
+ export declare function getSummarizationFailure(response: AssistantMessage, label: string): string | undefined;
93
98
  /**
94
99
  * Shared choke point for every compaction/branch-summary summarization call. Wraps the
95
100
  * single LLM call in {@link retryAssistantCall} so transient stream drops (e.g.
@@ -423,6 +423,19 @@ Use this EXACT format:
423
423
  - [Preserve important context, add new if needed]
424
424
 
425
425
  Keep each section concise. Preserve exact file paths, function names, and error messages.`;
426
+ /**
427
+ * Returns an error message when a summarization response cannot safely be persisted.
428
+ * A length stop contains partial text and must not become a session checkpoint.
429
+ */
430
+ export function getSummarizationFailure(response, label) {
431
+ if (response.stopReason === "error") {
432
+ return `${label} failed: ${response.errorMessage || "Unknown error"}`;
433
+ }
434
+ if (response.stopReason === "length") {
435
+ return `${label} failed: generation hit the token cap and the summary is incomplete`;
436
+ }
437
+ return undefined;
438
+ }
426
439
  function createSummarizationOptions(model, maxTokens, apiKey, headers, env, signal, thinkingLevel) {
427
440
  const options = { maxTokens, signal, apiKey, headers, env };
428
441
  if (model.reasoning && thinkingLevel && thinkingLevel !== "off") {
@@ -483,8 +496,9 @@ export async function generateSummaryWithUsage(currentMessages, model, reserveTo
483
496
  ];
484
497
  const completionOptions = createSummarizationOptions(model, maxTokens, apiKey, headers, env, signal, thinkingLevel);
485
498
  const response = await completeSummarization(model, { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, completionOptions, streamFn, retry, callbacks);
486
- if (response.stopReason === "error") {
487
- throw new Error(`Summarization failed: ${response.errorMessage || "Unknown error"}`);
499
+ const failure = getSummarizationFailure(response, "Summarization");
500
+ if (failure) {
501
+ throw new Error(failure);
488
502
  }
489
503
  const textContent = contentText(response.content);
490
504
  return { text: textContent, usage: response.usage };
@@ -634,8 +648,9 @@ async function generateTurnPrefixSummary(messages, model, reserveTokens, apiKey,
634
648
  },
635
649
  ];
636
650
  const response = await completeSummarization(model, { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, createSummarizationOptions(model, maxTokens, apiKey, headers, env, signal, thinkingLevel), streamFn, retry, callbacks);
637
- if (response.stopReason === "error") {
638
- throw new Error(`Turn prefix summarization failed: ${response.errorMessage || "Unknown error"}`);
651
+ const failure = getSummarizationFailure(response, "Turn prefix summarization");
652
+ if (failure) {
653
+ throw new Error(failure);
639
654
  }
640
655
  return {
641
656
  text: contentText(response.content),
@@ -6,6 +6,6 @@ export type { SourceInfo } from "../source-info.ts";
6
6
  export { createExtensionRuntime, discoverAndLoadExtensions, loadExtensionFromFactory, loadExtensions, } from "./loader.ts";
7
7
  export type { ExtensionErrorListener, ForkHandler, NavigateTreeHandler, NewSessionHandler, ShutdownHandler, SwitchSessionHandler, } from "./runner.ts";
8
8
  export { ExtensionRunner } from "./runner.ts";
9
- export type { AfterProviderResponseEvent, AgentEndEvent, AgentSettledEvent, AgentStartEvent, AgentToolResult, AgentToolUpdateCallback, AppendEntryHandler, AppKeybinding, AutocompleteProviderFactory, BashToolCallEvent, BashToolResultEvent, BeforeAgentStartEvent, BeforeAgentStartEventResult, BeforeProviderHeadersEvent, BeforeProviderRequestEvent, BeforeProviderRequestEventResult, BuildSystemPromptOptions, CompactOptions, ContextEvent, ContextEventResult, ContextUsage, CustomToolCallEvent, CustomToolResultEvent, EditorFactory, EditToolCallEvent, EditToolResultEvent, EntryRenderer, EntryRenderOptions, ExecOptions, ExecResult, Extension, ExtensionActions, ExtensionAPI, ExtensionCommandContext, ExtensionCommandContextActions, ExtensionContext, ExtensionContextActions, ExtensionError, ExtensionEvent, ExtensionFactory, ExtensionFlag, ExtensionHandler, ExtensionMode, ExtensionRuntime, ExtensionShortcut, ExtensionUIContext, ExtensionUIDialogOptions, ExtensionWidgetOptions, FindToolCallEvent, FindToolResultEvent, GetActiveToolsHandler, GetAllToolsHandler, GetCommandsHandler, GetThinkingLevelHandler, GrepToolCallEvent, GrepToolResultEvent, InlineExtension, InputEvent, InputEventResult, InputSource, KeybindingsManager, LoadExtensionsResult, LsToolCallEvent, LsToolResultEvent, MessageEndEvent, MessageRenderer, MessageRenderOptions, MessageStartEvent, MessageUpdateEvent, ModelSelectEvent, ModelSelectSource, ProjectTrustContext, ProjectTrustEvent, ProjectTrustEventDecision, ProjectTrustEventResult, ProjectTrustHandler, ProviderConfig, ProviderModelConfig, ReadToolCallEvent, ReadToolResultEvent, RegisteredCommand, RegisteredTool, ReplacedSessionContext, ResolvedCommand, ResourcesDiscoverEvent, ResourcesDiscoverResult, SendMessageHandler, SendUserMessageHandler, SessionBeforeCompactEvent, SessionBeforeCompactResult, SessionBeforeForkEvent, SessionBeforeForkResult, SessionBeforeSwitchEvent, SessionBeforeSwitchResult, SessionBeforeTreeEvent, SessionBeforeTreeResult, SessionCompactEvent, SessionEvent, SessionInfoChangedEvent, SessionShutdownEvent, SessionStartEvent, SessionTreeEvent, SetActiveToolsHandler, SetLabelHandler, SetModelHandler, SetThinkingLevelHandler, TerminalInputHandler, ToolCallEvent, ToolCallEventResult, ToolDefinition, ToolExecutionEndEvent, ToolExecutionMode, ToolExecutionStartEvent, ToolExecutionUpdateEvent, ToolInfo, ToolRenderResultOptions, ToolResultEvent, ToolResultEventResult, TreePreparation, TurnEndEvent, TurnStartEvent, UserBashEvent, UserBashEventResult, WidgetPlacement, WorkingIndicatorOptions, WriteToolCallEvent, WriteToolResultEvent, } from "./types.ts";
9
+ export type { AfterProviderResponseEvent, AgentEndEvent, AgentSettledEvent, AgentStartEvent, AgentToolResult, AgentToolUpdateCallback, AppendEntryHandler, AppKeybinding, AutocompleteProviderFactory, BashToolCallEvent, BashToolResultEvent, BeforeAgentStartEvent, BeforeAgentStartEventResult, BeforeProviderHeadersEvent, BeforeProviderRequestEvent, BeforeProviderRequestEventResult, BuildSystemPromptOptions, CompactOptions, ContextEvent, ContextEventResult, ContextUsage, CustomToolCallEvent, CustomToolResultEvent, EditorFactory, EditToolCallEvent, EditToolResultEvent, EntryRenderer, EntryRenderOptions, ExecOptions, ExecResult, Extension, ExtensionActions, ExtensionAPI, ExtensionCommandContext, ExtensionCommandContextActions, ExtensionContext, ExtensionContextActions, ExtensionError, ExtensionEvent, ExtensionFactory, ExtensionFlag, ExtensionHandler, ExtensionMode, ExtensionRuntime, ExtensionShortcut, ExtensionUIContext, ExtensionUIDialogOptions, ExtensionWidgetOptions, FindToolCallEvent, FindToolResultEvent, GetActiveToolsHandler, GetAllToolsHandler, GetCommandsHandler, GetThinkingLevelHandler, GrepToolCallEvent, GrepToolResultEvent, InlineExtension, InputEvent, InputEventResult, InputSource, KeybindingsManager, LoadExtensionsResult, LsToolCallEvent, LsToolResultEvent, MessageEndEvent, MessageRenderer, MessageRenderOptions, MessageStartEvent, MessageUpdateEvent, ModelSelectEvent, ModelSelectSource, ProjectTrustContext, ProjectTrustEvent, ProjectTrustEventDecision, ProjectTrustEventResult, ProjectTrustHandler, ProviderConfig, ProviderModelConfig, ReadToolCallEvent, ReadToolResultEvent, RegisteredCommand, RegisteredTool, ReplacedSessionContext, ResolvedCommand, ResourcesDiscoverEvent, ResourcesDiscoverResult, SendMessageHandler, SendUserMessageHandler, SessionBeforeCompactEvent, SessionBeforeCompactResult, SessionBeforeForkEvent, SessionBeforeForkResult, SessionBeforeSwitchEvent, SessionBeforeSwitchResult, SessionBeforeTreeEvent, SessionBeforeTreeResult, SessionCompactEvent, SessionEvent, SessionInfoChangedEvent, SessionShutdownEvent, SessionStartEvent, SessionTreeEvent, SetActiveToolsHandler, SetLabelHandler, SetModelHandler, SetThinkingLevelHandler, TerminalInputHandler, ToolCallEvent, ToolCallEventResult, ToolDefinition, ToolExecutionEndEvent, ToolExecutionMode, ToolExecutionStartEvent, ToolExecutionUpdateEvent, ToolInfo, ToolRenderResultOptions, ToolResultEvent, ToolResultEventResult, TreePreparation, TurnEndEvent, TurnStartEvent, UIPromptEndEvent, UIPromptKind, UIPromptStartEvent, UserBashEvent, UserBashEventResult, WidgetPlacement, WorkingIndicatorOptions, WriteToolCallEvent, WriteToolResultEvent, } from "./types.ts";
10
10
  export { defineTool, isBashToolResult, isEditToolResult, isFindToolResult, isGrepToolResult, isLsToolResult, isReadToolResult, isToolCallEventType, isWriteToolResult, } from "./types.ts";
11
11
  export { wrapRegisteredTool, wrapRegisteredTools } from "./wrapper.ts";
@@ -72,6 +72,8 @@ export declare class ExtensionRunner {
72
72
  private runtime;
73
73
  private uiContext;
74
74
  private mode;
75
+ private uiPromptDepth;
76
+ private activeUIPrompt;
75
77
  private cwd;
76
78
  private sessionManager;
77
79
  private modelRegistry;
@@ -105,6 +107,9 @@ export declare class ExtensionRunner {
105
107
  }): void;
106
108
  bindCommandContext(actions?: ExtensionCommandContextActions): void;
107
109
  setUIContext(uiContext?: ExtensionUIContext, mode?: ExtensionMode): void;
110
+ private wrapUIPromptContext;
111
+ private withUIPrompt;
112
+ private emitUIPromptEvent;
108
113
  getUIContext(): ExtensionUIContext;
109
114
  hasUI(): boolean;
110
115
  getExtensionPaths(): string[];
@@ -122,6 +122,8 @@ export class ExtensionRunner {
122
122
  runtime;
123
123
  uiContext;
124
124
  mode = "print";
125
+ uiPromptDepth = 0;
126
+ activeUIPrompt;
125
127
  cwd;
126
128
  sessionManager;
127
129
  modelRegistry;
@@ -265,9 +267,54 @@ export class ExtensionRunner {
265
267
  this.reloadHandler = async () => { };
266
268
  }
267
269
  setUIContext(uiContext, mode = "print") {
268
- this.uiContext = uiContext ?? noOpUIContext;
270
+ this.uiContext = uiContext ? this.wrapUIPromptContext(uiContext) : noOpUIContext;
269
271
  this.mode = mode;
270
272
  }
273
+ wrapUIPromptContext(ui) {
274
+ return {
275
+ ...ui,
276
+ select: (title, options, opts) => this.withUIPrompt("select", title, () => ui.select(title, options, opts)),
277
+ multiselect: ui.multiselect
278
+ ? (title, options, opts) => this.withUIPrompt("select", title, () => ui.multiselect(title, options, opts))
279
+ : undefined,
280
+ confirm: (title, message, opts) => this.withUIPrompt("confirm", title, () => ui.confirm(title, message, opts)),
281
+ input: (title, placeholder, opts) => this.withUIPrompt("input", title, () => ui.input(title, placeholder, opts)),
282
+ editor: (title, prefill) => this.withUIPrompt("editor", title, () => ui.editor(title, prefill)),
283
+ custom: (factory, options) => this.withUIPrompt("custom", undefined, () => ui.custom(factory, options)),
284
+ };
285
+ }
286
+ withUIPrompt(kind, title, run) {
287
+ const outerPrompt = this.uiPromptDepth++ === 0;
288
+ if (outerPrompt) {
289
+ this.activeUIPrompt = { kind, title };
290
+ this.emitUIPromptEvent({ type: "ui_prompt_start", reason: "ui_prompt", kind, ...(title ? { title } : {}) });
291
+ }
292
+ const finish = () => {
293
+ if (--this.uiPromptDepth > 0)
294
+ return;
295
+ this.uiPromptDepth = 0;
296
+ const prompt = this.activeUIPrompt ?? { kind, title };
297
+ this.activeUIPrompt = undefined;
298
+ this.emitUIPromptEvent({
299
+ type: "ui_prompt_end",
300
+ reason: "ui_prompt",
301
+ kind: prompt.kind,
302
+ ...(prompt.title ? { title: prompt.title } : {}),
303
+ });
304
+ };
305
+ try {
306
+ return run().finally(finish);
307
+ }
308
+ catch (err) {
309
+ finish();
310
+ throw err;
311
+ }
312
+ }
313
+ emitUIPromptEvent(event) {
314
+ queueMicrotask(() => {
315
+ void this.emit(event);
316
+ });
317
+ }
271
318
  getUIContext() {
272
319
  return this.uiContext;
273
320
  }
@@ -563,6 +563,21 @@ export interface AgentEndEvent {
563
563
  export interface AgentSettledEvent {
564
564
  type: "agent_settled";
565
565
  }
566
+ export type UIPromptKind = "select" | "confirm" | "input" | "editor" | "custom";
567
+ /** Fired when Pi starts waiting on a blocking user-facing extension UI prompt. */
568
+ export interface UIPromptStartEvent {
569
+ type: "ui_prompt_start";
570
+ reason: "ui_prompt";
571
+ kind: UIPromptKind;
572
+ title?: string;
573
+ }
574
+ /** Fired when Pi is no longer waiting on a blocking user-facing extension UI prompt. */
575
+ export interface UIPromptEndEvent {
576
+ type: "ui_prompt_end";
577
+ reason: "ui_prompt";
578
+ kind: UIPromptKind;
579
+ title?: string;
580
+ }
566
581
  /** Fired at the start of each turn */
567
582
  export interface TurnStartEvent {
568
583
  type: "turn_start";
@@ -788,7 +803,7 @@ export declare function isToolCallEventType<TName extends string, TInput extends
788
803
  input: TInput;
789
804
  };
790
805
  /** Union of all event types */
791
- export type ExtensionEvent = ProjectTrustEvent | ResourcesDiscoverEvent | SessionEvent | ContextEvent | BeforeProviderRequestEvent | BeforeProviderHeadersEvent | AfterProviderResponseEvent | BeforeAgentStartEvent | AgentStartEvent | AgentEndEvent | AgentSettledEvent | TurnStartEvent | TurnEndEvent | MessageStartEvent | MessageUpdateEvent | MessageEndEvent | ToolExecutionStartEvent | ToolExecutionUpdateEvent | ToolExecutionEndEvent | ModelSelectEvent | ThinkingLevelSelectEvent | UserBashEvent | InputEvent | ToolCallEvent | ToolResultEvent;
806
+ export type ExtensionEvent = ProjectTrustEvent | ResourcesDiscoverEvent | SessionEvent | ContextEvent | BeforeProviderRequestEvent | BeforeProviderHeadersEvent | AfterProviderResponseEvent | BeforeAgentStartEvent | AgentStartEvent | AgentEndEvent | AgentSettledEvent | UIPromptStartEvent | UIPromptEndEvent | TurnStartEvent | TurnEndEvent | MessageStartEvent | MessageUpdateEvent | MessageEndEvent | ToolExecutionStartEvent | ToolExecutionUpdateEvent | ToolExecutionEndEvent | ModelSelectEvent | ThinkingLevelSelectEvent | UserBashEvent | InputEvent | ToolCallEvent | ToolResultEvent;
792
807
  export interface ContextEventResult {
793
808
  messages?: AgentMessage[];
794
809
  }
@@ -894,6 +909,8 @@ export interface ExtensionAPI {
894
909
  on(event: "agent_start", handler: ExtensionHandler<AgentStartEvent>): void;
895
910
  on(event: "agent_end", handler: ExtensionHandler<AgentEndEvent>): void;
896
911
  on(event: "agent_settled", handler: ExtensionHandler<AgentSettledEvent>): void;
912
+ on(event: "ui_prompt_start", handler: ExtensionHandler<UIPromptStartEvent>): void;
913
+ on(event: "ui_prompt_end", handler: ExtensionHandler<UIPromptEndEvent>): void;
897
914
  on(event: "turn_start", handler: ExtensionHandler<TurnStartEvent>): void;
898
915
  on(event: "turn_end", handler: ExtensionHandler<TurnEndEvent>): void;
899
916
  on(event: "message_start", handler: ExtensionHandler<MessageStartEvent>): void;
@@ -28,6 +28,9 @@ export interface LlamaModelsResponse {
28
28
  data: LlamaModelInfo[];
29
29
  object?: string;
30
30
  }
31
+ export interface LlamaServerProps {
32
+ models_autoload?: boolean;
33
+ }
31
34
  export interface LlamaModelEvent {
32
35
  model: string;
33
36
  event: string;
@@ -50,6 +53,9 @@ export declare class LlamaClient {
50
53
  reload?: boolean;
51
54
  signal?: AbortSignal;
52
55
  }): Promise<LlamaModelInfo[]>;
56
+ props(options?: {
57
+ signal?: AbortSignal;
58
+ }): Promise<LlamaServerProps>;
53
59
  load(model: string, signal?: AbortSignal): Promise<void>;
54
60
  unload(model: string, signal?: AbortSignal): Promise<void>;
55
61
  unloadAndWait(model: string, signal?: AbortSignal): Promise<void>;
@@ -150,6 +150,13 @@ export class LlamaClient {
150
150
  throw new Error("Server is not running in llama.cpp router mode");
151
151
  return data;
152
152
  }
153
+ async props(options = {}) {
154
+ const payload = await this.request("/props", { signal: options.signal });
155
+ if (typeof payload !== "object" || payload === null)
156
+ return {};
157
+ const { models_autoload: modelsAutoload } = payload;
158
+ return typeof modelsAutoload === "boolean" ? { models_autoload: modelsAutoload } : {};
159
+ }
153
160
  async load(model, signal) {
154
161
  await this.request("/models/load", { method: "POST", body: JSON.stringify({ model }), signal });
155
162
  }
@@ -4,6 +4,8 @@ export declare const LLAMA_PROVIDER_ID = "llama.cpp";
4
4
  export declare const DEFAULT_LLAMA_SERVER_URL = "http://127.0.0.1:8080";
5
5
  export interface LlamaProviderController {
6
6
  provider: Provider<"openai-completions">;
7
- setCatalog(models: readonly LlamaModelInfo[], serverUrl: string): void;
7
+ setCatalog(models: readonly LlamaModelInfo[], serverUrl: string, options?: {
8
+ routerAutoload?: boolean;
9
+ }): void;
8
10
  }
9
11
  export declare function createLlamaProvider(): LlamaProviderController;
@@ -10,6 +10,22 @@ async function resolveServerUrl(ctx, credential) {
10
10
  const configured = credentialServerUrl(credential) ?? (await ctx.env("LLAMA_BASE_URL"))?.trim();
11
11
  return configured ? normalizeLlamaServerUrl(configured) : undefined;
12
12
  }
13
+ function modelIsSelectable(model, routerAutoload) {
14
+ if (model.status.value === "loaded")
15
+ return true;
16
+ // Unloaded presets are routable only when llama.cpp router autoload can load them on first use.
17
+ return routerAutoload && model.status.value === "unloaded" && !model.status.failed && model.source === "preset";
18
+ }
19
+ async function routerAutoloadEnabled(client, catalog, signal) {
20
+ if (!catalog.some((model) => model.status.value === "unloaded" && model.source === "preset"))
21
+ return false;
22
+ try {
23
+ return (await client.props({ signal })).models_autoload === true;
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+ }
13
29
  function toPiModel(model, serverUrl) {
14
30
  const reportedContextWindow = model.meta?.n_ctx ?? model.meta?.n_ctx_train;
15
31
  const contextWindow = reportedContextWindow && reportedContextWindow > 0 ? reportedContextWindow : 128000;
@@ -36,8 +52,10 @@ function toPiModel(model, serverUrl) {
36
52
  }
37
53
  export function createLlamaProvider() {
38
54
  let models = [];
39
- const setCatalog = (catalog, serverUrl) => {
40
- models = catalog.filter((model) => model.status.value === "loaded").map((model) => toPiModel(model, serverUrl));
55
+ const setCatalog = (catalog, serverUrl, options = {}) => {
56
+ models = catalog
57
+ .filter((model) => modelIsSelectable(model, options.routerAutoload === true))
58
+ .map((model) => toPiModel(model, serverUrl));
41
59
  };
42
60
  const provider = {
43
61
  id: LLAMA_PROVIDER_ID,
@@ -101,12 +119,16 @@ export function createLlamaProvider() {
101
119
  const serverUrl = credentialServerUrl(context.credential);
102
120
  if (!serverUrl)
103
121
  return;
104
- const catalog = await new LlamaClient(serverUrl, context.credential.key).list({ signal: context.signal });
105
- const refreshed = catalog
106
- .filter((model) => model.status.value === "loaded")
107
- .map((model) => toPiModel(model, serverUrl));
122
+ const client = new LlamaClient(serverUrl, context.credential.key);
123
+ const catalog = await client.list({ signal: context.signal });
108
124
  if (context.signal.aborted)
109
125
  return;
126
+ const routerAutoload = await routerAutoloadEnabled(client, catalog, context.signal);
127
+ if (context.signal.aborted)
128
+ return;
129
+ const refreshed = catalog
130
+ .filter((model) => modelIsSelectable(model, routerAutoload))
131
+ .map((model) => toPiModel(model, serverUrl));
110
132
  await context.publish({
111
133
  persist: { models: refreshed, checkedAt: Date.now() },
112
134
  update: () => {
@@ -279,11 +279,11 @@ export function loadEntriesFromFile(filePath) {
279
279
  if (!existsSync(resolvedFilePath))
280
280
  return [];
281
281
  const entries = [];
282
+ let pending = "";
282
283
  const fd = openSync(resolvedFilePath, "r");
283
284
  try {
284
285
  const decoder = new StringDecoder("utf8");
285
286
  const buffer = Buffer.allocUnsafe(SESSION_READ_BUFFER_SIZE);
286
- let pending = "";
287
287
  while (true) {
288
288
  const bytesRead = readSync(fd, buffer, 0, buffer.length, null);
289
289
  if (bytesRead === 0)
@@ -308,13 +308,15 @@ export function loadEntriesFromFile(filePath) {
308
308
  finally {
309
309
  closeSync(fd);
310
310
  }
311
- // Validate session header
311
+ // Validate session header before repairing the file.
312
312
  if (entries.length === 0)
313
313
  return entries;
314
314
  const header = entries[0];
315
315
  if (header.type !== "session" || typeof header.id !== "string") {
316
316
  return [];
317
317
  }
318
+ if (pending)
319
+ appendFileSync(resolvedFilePath, "\n");
318
320
  return entries;
319
321
  }
320
322
  /**