@f5xc-salesdemos/xcsh 17.0.1 → 17.1.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,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5xc-salesdemos/xcsh",
4
- "version": "17.0.1",
4
+ "version": "17.1.0",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/f5xc-salesdemos/xcsh",
7
7
  "author": "Can Boluk",
@@ -46,12 +46,12 @@
46
46
  "dependencies": {
47
47
  "@agentclientprotocol/sdk": "0.16.1",
48
48
  "@mozilla/readability": "^0.6",
49
- "@f5xc-salesdemos/xcsh-stats": "17.0.1",
50
- "@f5xc-salesdemos/pi-agent-core": "17.0.1",
51
- "@f5xc-salesdemos/pi-ai": "17.0.1",
52
- "@f5xc-salesdemos/pi-natives": "17.0.1",
53
- "@f5xc-salesdemos/pi-tui": "17.0.1",
54
- "@f5xc-salesdemos/pi-utils": "17.0.1",
49
+ "@f5xc-salesdemos/xcsh-stats": "17.1.0",
50
+ "@f5xc-salesdemos/pi-agent-core": "17.1.0",
51
+ "@f5xc-salesdemos/pi-ai": "17.1.0",
52
+ "@f5xc-salesdemos/pi-natives": "17.1.0",
53
+ "@f5xc-salesdemos/pi-tui": "17.1.0",
54
+ "@f5xc-salesdemos/pi-utils": "17.1.0",
55
55
  "@sinclair/typebox": "^0.34",
56
56
  "@xterm/headless": "^6.0",
57
57
  "ajv": "^8.18",
@@ -25,9 +25,10 @@ import {
25
25
  unregisterCustomApis,
26
26
  unregisterOAuthProviders,
27
27
  } from "@f5xc-salesdemos/pi-ai";
28
- import { isRecord, logger } from "@f5xc-salesdemos/pi-utils";
28
+ import { $env, isRecord, logger } from "@f5xc-salesdemos/pi-utils";
29
29
  import { type Static, Type } from "@sinclair/typebox";
30
30
  import { type ConfigError, ConfigFile } from "../config";
31
+ import { hasLiteLLMEnv, probeAndUpgradeLiteLLMConfig, startupHealthCheck } from "../config/auto-config";
31
32
  import { parseModelString, resolveProviderModelReference } from "../config/model-resolver";
32
33
  import { isValidThemeColor, type ThemeColor } from "../modes/theme/theme";
33
34
  import type { AuthStorage, OAuthCredential } from "../session/auth-storage";
@@ -766,6 +767,7 @@ export class ModelRegistry {
766
767
  #suppressedSelectors: Map<string, number> = new Map();
767
768
  #backgroundRefresh?: Promise<void>;
768
769
  #lastDiscoveryWarnings: Map<string, string> = new Map();
770
+ #hasProbed = false;
769
771
  // Runtime extension model overlays — persist across refresh() cycles so that
770
772
  // models registered by extensions survive the model selector's offline reload.
771
773
  #runtimeModelOverlays: CustomModelOverlay[] = [];
@@ -798,6 +800,14 @@ export class ModelRegistry {
798
800
  * Reload models from disk (built-in + custom from models.json).
799
801
  */
800
802
  async refresh(strategy: ModelRefreshStrategy = "online-if-uncached"): Promise<void> {
803
+ // On first refresh, probe LiteLLM proxy and upgrade config with discovery if available
804
+ if (!this.#hasProbed && hasLiteLLMEnv()) {
805
+ this.#hasProbed = true;
806
+ const upgraded = await probeAndUpgradeLiteLLMConfig(this.#modelsConfigFile.path());
807
+ if (upgraded) {
808
+ this.#modelsConfigFile.invalidate();
809
+ }
810
+ }
801
811
  this.#reloadStaticModels();
802
812
  this.#suppressedSelectors.clear();
803
813
  await this.#refreshRuntimeDiscoveries(strategy);
@@ -1041,7 +1051,22 @@ export class ModelRegistry {
1041
1051
  }
1042
1052
 
1043
1053
  #loadCustomModels(): CustomModelsResult {
1044
- const { value, error, status } = this.#modelsConfigFile.tryLoad();
1054
+ let result = this.#modelsConfigFile.tryLoad();
1055
+
1056
+ // Self-healing: detect missing, corrupt, or drifted config and auto-repair from env vars
1057
+ if (result.status !== "ok" || result.value) {
1058
+ const repaired = startupHealthCheck(
1059
+ result.status,
1060
+ this.#modelsConfigFile.path(),
1061
+ result.status === "ok" && result.value ? result.value.providers : undefined,
1062
+ );
1063
+ if (repaired) {
1064
+ this.#modelsConfigFile.invalidate();
1065
+ result = this.#modelsConfigFile.tryLoad();
1066
+ }
1067
+ }
1068
+
1069
+ const { value, error, status } = result;
1045
1070
 
1046
1071
  if (status === "error") {
1047
1072
  return {
@@ -1282,10 +1307,30 @@ export class ModelRegistry {
1282
1307
  ): Promise<Model<Api>[]> {
1283
1308
  // Skip providers already handled by configured discovery (e.g. user-configured ollama with discovery.type)
1284
1309
  const configuredDiscoveryProviders = new Set(this.#discoverableProviders.map(p => p.provider));
1310
+
1311
+ // When a LiteLLM proxy is configured, providers whose baseUrl points to the
1312
+ // LiteLLM proxy are proxied through it. Their built-in discovery would query
1313
+ // the proxy's model listing endpoint, which may return model IDs the proxy
1314
+ // can't serve for chat. Skip them — the litellm discovery provider handles
1315
+ // model listing instead. Providers with other custom baseUrls (not LiteLLM)
1316
+ // still need built-in discovery to discover new models at their endpoint.
1317
+ const liteLLMBaseUrl = hasLiteLLMEnv() ? $env.LITELLM_BASE_URL?.trim().replace(/\/+$/, "") : undefined;
1318
+ const proxiedProviders = liteLLMBaseUrl
1319
+ ? new Set(
1320
+ [...this.#providerOverrides.keys()].filter(id => {
1321
+ const override = this.#providerOverrides.get(id);
1322
+ return override?.baseUrl?.startsWith(liteLLMBaseUrl);
1323
+ }),
1324
+ )
1325
+ : new Set<string>();
1326
+
1285
1327
  const managerOptions = (await this.#collectBuiltInModelManagerOptions()).filter(opts => {
1286
1328
  if (configuredDiscoveryProviders.has(opts.providerId)) {
1287
1329
  return false;
1288
1330
  }
1331
+ if (proxiedProviders.has(opts.providerId)) {
1332
+ return false;
1333
+ }
1289
1334
  return providerFilter ? providerFilter.has(opts.providerId) : true;
1290
1335
  });
1291
1336
  if (managerOptions.length === 0) {
@@ -243,7 +243,7 @@ export const SETTINGS_SCHEMA = {
243
243
  // Theme
244
244
  "theme.dark": {
245
245
  type: "string",
246
- default: "titanium",
246
+ default: "xcsh-dark",
247
247
  ui: {
248
248
  tab: "appearance",
249
249
  label: "Dark Theme",
@@ -254,7 +254,7 @@ export const SETTINGS_SCHEMA = {
254
254
 
255
255
  "theme.light": {
256
256
  type: "string",
257
- default: "light",
257
+ default: "xcsh-light",
258
258
  ui: {
259
259
  tab: "appearance",
260
260
  label: "Light Theme",
@@ -266,7 +266,7 @@ export const SETTINGS_SCHEMA = {
266
266
  symbolPreset: {
267
267
  type: "enum",
268
268
  values: ["unicode", "nerd", "ascii"] as const,
269
- default: "unicode",
269
+ default: "nerd",
270
270
  ui: { tab: "appearance", label: "Symbol Preset", description: "Icon/symbol style", submenu: true },
271
271
  },
272
272
 
@@ -284,7 +284,7 @@ export const SETTINGS_SCHEMA = {
284
284
  "statusLine.preset": {
285
285
  type: "enum",
286
286
  values: ["default", "minimal", "compact", "full", "nerd", "ascii", "xcsh", "custom"] as const,
287
- default: "default",
287
+ default: "xcsh",
288
288
  ui: {
289
289
  tab: "appearance",
290
290
  label: "Status Line Preset",
@@ -296,7 +296,7 @@ export const SETTINGS_SCHEMA = {
296
296
  "statusLine.separator": {
297
297
  type: "enum",
298
298
  values: ["powerline", "powerline-thin", "slash", "pipe", "block", "none", "ascii"] as const,
299
- default: "powerline-thin",
299
+ default: "powerline",
300
300
  ui: {
301
301
  tab: "appearance",
302
302
  label: "Status Line Separator",
@@ -664,7 +664,7 @@ export const SETTINGS_SCHEMA = {
664
664
 
665
665
  collapseChangelog: {
666
666
  type: "boolean",
667
- default: false,
667
+ default: true,
668
668
  ui: { tab: "interaction", label: "Collapse Changelog", description: "Show condensed changelog after updates" },
669
669
  },
670
670
 
@@ -697,7 +697,7 @@ export const SETTINGS_SCHEMA = {
697
697
  // Speech-to-text
698
698
  "stt.enabled": {
699
699
  type: "boolean",
700
- default: false,
700
+ default: true,
701
701
  ui: { tab: "interaction", label: "Speech-to-Text", description: "Enable speech-to-text input via microphone" },
702
702
  },
703
703
 
@@ -785,7 +785,7 @@ export const SETTINGS_SCHEMA = {
785
785
 
786
786
  "compaction.handoffSaveToDisk": {
787
787
  type: "boolean",
788
- default: false,
788
+ default: true,
789
789
  ui: {
790
790
  tab: "context",
791
791
  label: "Save Handoff Docs",
@@ -855,7 +855,7 @@ export const SETTINGS_SCHEMA = {
855
855
  // Memories
856
856
  "memories.enabled": {
857
857
  type: "boolean",
858
- default: false,
858
+ default: true,
859
859
  ui: {
860
860
  tab: "context",
861
861
  label: "Memories",
@@ -1243,7 +1243,7 @@ export const SETTINGS_SCHEMA = {
1243
1243
 
1244
1244
  "renderMermaid.enabled": {
1245
1245
  type: "boolean",
1246
- default: false,
1246
+ default: true,
1247
1247
  ui: {
1248
1248
  tab: "tools",
1249
1249
  label: "Render Mermaid",
@@ -1263,7 +1263,7 @@ export const SETTINGS_SCHEMA = {
1263
1263
 
1264
1264
  "calc.enabled": {
1265
1265
  type: "boolean",
1266
- default: false,
1266
+ default: true,
1267
1267
  ui: {
1268
1268
  tab: "tools",
1269
1269
  label: "Calculator",
@@ -1300,7 +1300,7 @@ export const SETTINGS_SCHEMA = {
1300
1300
 
1301
1301
  "github.enabled": {
1302
1302
  type: "boolean",
1303
- default: false,
1303
+ default: true,
1304
1304
  ui: {
1305
1305
  tab: "tools",
1306
1306
  label: "GitHub CLI",
@@ -1676,13 +1676,13 @@ export const SETTINGS_SCHEMA = {
1676
1676
  // Exa
1677
1677
  "exa.enabled": {
1678
1678
  type: "boolean",
1679
- default: true,
1679
+ default: false,
1680
1680
  ui: { tab: "providers", label: "Exa", description: "Master toggle for all Exa search tools" },
1681
1681
  },
1682
1682
 
1683
1683
  "exa.enableSearch": {
1684
1684
  type: "boolean",
1685
- default: true,
1685
+ default: false,
1686
1686
  ui: { tab: "providers", label: "Exa Search", description: "Basic search, deep search, code search, crawl" },
1687
1687
  },
1688
1688
 
@@ -1,11 +1,19 @@
1
+ import * as fs from "node:fs";
1
2
  import * as os from "node:os";
2
3
  import * as path from "node:path";
3
4
  import { ThinkingLevel } from "@f5xc-salesdemos/pi-agent-core";
4
- import { getOAuthProviders, type OAuthProvider } from "@f5xc-salesdemos/pi-ai";
5
+ import { getOAuthProviders, loginLiteLLM, type OAuthProvider } from "@f5xc-salesdemos/pi-ai";
5
6
  import type { Component } from "@f5xc-salesdemos/pi-tui";
6
7
  import { Input, Loader, Spacer, Text } from "@f5xc-salesdemos/pi-tui";
7
- import { getAgentDbPath, getConfigDirName, getProjectDir } from "@f5xc-salesdemos/pi-utils";
8
+ import { getAgentDbPath, getAgentDir, getConfigDirName, getProjectDir } from "@f5xc-salesdemos/pi-utils";
8
9
  import { invalidate as invalidateFsCache } from "../../capability/fs";
10
+ import {
11
+ generateConfigYml,
12
+ generateModelsYml,
13
+ healConfigYmlModelRoles,
14
+ probeLiteLLMConnection,
15
+ readLiteLLMConfig,
16
+ } from "../../config/auto-config";
9
17
  import { getRoleInfo } from "../../config/model-registry";
10
18
  import { formatModelSelectorValue } from "../../config/model-resolver";
11
19
  import { settings } from "../../config/settings";
@@ -36,6 +44,7 @@ import { setSessionTerminalTitle } from "../../utils/title-generator";
36
44
  import { AgentDashboard } from "../components/agent-dashboard";
37
45
  import { AssistantMessageComponent } from "../components/assistant-message";
38
46
  import { ExtensionDashboard } from "../components/extensions";
47
+ import { GutterBlock } from "../components/gutter-block";
39
48
  import { HistorySearchComponent } from "../components/history-search";
40
49
  import { ModelSelectorComponent } from "../components/model-selector";
41
50
  import { OAuthSelectorComponent } from "../components/oauth-selector";
@@ -43,6 +52,7 @@ import { PluginSelectorComponent } from "../components/plugin-selector";
43
52
  import { SessionObserverOverlayComponent } from "../components/session-observer-overlay";
44
53
  import { SessionSelectorComponent } from "../components/session-selector";
45
54
  import { SettingsSelectorComponent } from "../components/settings-selector";
55
+ import { getPreset } from "../components/status-line/presets";
46
56
  import { ToolExecutionComponent } from "../components/tool-execution";
47
57
  import { TreeSelectorComponent } from "../components/tree-selector";
48
58
  import { UserMessageSelectorComponent } from "../components/user-message-selector";
@@ -260,16 +270,18 @@ export class SelectorController {
260
270
  // Settings with UI side effects
261
271
  case "showImages":
262
272
  for (const child of this.ctx.chatContainer.children) {
263
- if (child instanceof ToolExecutionComponent) {
264
- child.setShowImages(value as boolean);
273
+ const unwrapped = child instanceof GutterBlock ? child.child : child;
274
+ if (unwrapped instanceof ToolExecutionComponent) {
275
+ unwrapped.setShowImages(value as boolean);
265
276
  }
266
277
  }
267
278
  break;
268
279
  case "hideThinking":
269
280
  this.ctx.hideThinkingBlock = value as boolean;
270
281
  for (const child of this.ctx.chatContainer.children) {
271
- if (child instanceof AssistantMessageComponent) {
272
- child.setHideThinkingBlock(value as boolean);
282
+ const unwrapped = child instanceof GutterBlock ? child.child : child;
283
+ if (unwrapped instanceof AssistantMessageComponent) {
284
+ unwrapped.setHideThinkingBlock(value as boolean);
273
285
  }
274
286
  }
275
287
  this.ctx.chatContainer.clear();
@@ -344,6 +356,14 @@ export class SelectorController {
344
356
  case "statusLineGitShowUntracked":
345
357
  case "statusLineTimeFormat":
346
358
  case "statusLineTimeShowSeconds": {
359
+ // When selecting a non-custom preset, sync the preset's separator
360
+ // to the store so #resolveSettings picks it up correctly.
361
+ if (id === "statusLinePreset" && value !== "custom") {
362
+ const presetDef = getPreset(value as Parameters<typeof getPreset>[0]);
363
+ if (presetDef.separator) {
364
+ settings.set("statusLine.separator", presetDef.separator);
365
+ }
366
+ }
347
367
  const statusLineSettings = {
348
368
  preset: settings.get("statusLine.preset"),
349
369
  leftSegments: settings.get("statusLine.leftSegments"),
@@ -828,7 +848,100 @@ export class SelectorController {
828
848
  await this.showSessionSelector();
829
849
  }
830
850
 
851
+ async #handleLiteLLMLogin(): Promise<void> {
852
+ this.ctx.showStatus("Configuring LiteLLM proxy…");
853
+
854
+ // Read existing config for idempotent defaults
855
+ const modelsPath = path.join(getAgentDir(), "models.yml");
856
+
857
+ try {
858
+ const existing = readLiteLLMConfig(modelsPath);
859
+ // Sequential prompts for base URL + API key
860
+ const result = await loginLiteLLM({
861
+ defaults: existing,
862
+ onPrompt: async prompt => {
863
+ this.ctx.chatContainer.addChild(new Spacer(1));
864
+ this.ctx.chatContainer.addChild(new Text(theme.fg("text", prompt.message), 1, 0));
865
+ if (prompt.placeholder) {
866
+ this.ctx.chatContainer.addChild(new Text(theme.fg("dim", `e.g., ${prompt.placeholder}`), 1, 0));
867
+ }
868
+ this.ctx.ui.requestRender();
869
+
870
+ const { promise, resolve } = Promise.withResolvers<string>();
871
+ const codeInput = new Input();
872
+ codeInput.onSubmit = () => {
873
+ const value = codeInput.getValue();
874
+ this.ctx.editorContainer.clear();
875
+ this.ctx.editorContainer.addChild(this.ctx.editor);
876
+ this.ctx.ui.setFocus(this.ctx.editor);
877
+ resolve(value);
878
+ };
879
+ this.ctx.editorContainer.clear();
880
+ this.ctx.editorContainer.addChild(codeInput);
881
+ this.ctx.ui.setFocus(codeInput);
882
+ return promise;
883
+ },
884
+ });
885
+
886
+ // Verification
887
+ this.ctx.chatContainer.addChild(new Spacer(1));
888
+ this.ctx.chatContainer.addChild(new Text(theme.fg("dim", `Connecting to ${result.baseUrl}…`), 1, 0));
889
+ this.ctx.ui.requestRender();
890
+
891
+ const probe = await probeLiteLLMConnection(result.baseUrl, result.apiKey);
892
+
893
+ if (probe.reachable) {
894
+ this.ctx.chatContainer.addChild(
895
+ new Text(
896
+ theme.fg("success", `${theme.status.success} OK — ${probe.models.length} models available`),
897
+ 1,
898
+ 0,
899
+ ),
900
+ );
901
+ } else {
902
+ this.ctx.chatContainer.addChild(
903
+ new Text(theme.fg("error", `${theme.status.error} FAIL — ${probe.error ?? "connection failed"}`), 1, 0),
904
+ );
905
+ this.ctx.ui.requestRender();
906
+ return;
907
+ }
908
+
909
+ // Write models.yml with the literal API key so both providers work
910
+ // without requiring the LITELLM_API_KEY env var to be set
911
+ const yml = generateModelsYml(result.baseUrl, {
912
+ apiBasePath: probe.apiBasePath,
913
+ apiKeyLiteral: result.apiKey,
914
+ });
915
+ fs.mkdirSync(path.dirname(modelsPath), { recursive: true });
916
+ fs.writeFileSync(modelsPath, yml);
917
+
918
+ // Create/heal config.yml for model defaults
919
+ const configPath = path.join(path.dirname(modelsPath), "config.yml");
920
+ if (!fs.existsSync(configPath)) {
921
+ fs.writeFileSync(configPath, generateConfigYml());
922
+ }
923
+ healConfigYmlModelRoles(configPath);
924
+
925
+ // Force online refresh so the registry re-probes the new proxy
926
+ // instead of serving stale data from the in-process SQLite cache.
927
+ await this.ctx.session.modelRegistry.refresh("online");
928
+
929
+ this.ctx.chatContainer.addChild(new Spacer(1));
930
+ this.ctx.chatContainer.addChild(
931
+ new Text(theme.fg("success", `LiteLLM configuration saved to ${modelsPath}`), 1, 0),
932
+ );
933
+ this.ctx.ui.requestRender();
934
+ } catch (error: unknown) {
935
+ this.ctx.showError(`LiteLLM login failed: ${error instanceof Error ? error.message : String(error)}`);
936
+ }
937
+ }
938
+
831
939
  async #handleOAuthLogin(providerId: string): Promise<void> {
940
+ // LiteLLM has its own flow with config persistence
941
+ if (providerId === "litellm") {
942
+ return this.#handleLiteLLMLogin();
943
+ }
944
+
832
945
  this.ctx.showStatus(`Logging in to ${providerId}…`);
833
946
  const manualInput = this.ctx.oauthManualInput;
834
947
  const useManualInput = CALLBACK_SERVER_PROVIDERS.has(providerId as OAuthProvider);
package/src/sdk.ts CHANGED
@@ -88,6 +88,8 @@ import {
88
88
  deobfuscateSessionContext,
89
89
  loadSecrets,
90
90
  obfuscateMessages,
91
+ SECRET_ENV_PATTERNS,
92
+ type SecretEntry,
91
93
  SecretObfuscator,
92
94
  } from "./secrets";
93
95
  import { AgentSession } from "./session/agent-session";
@@ -701,17 +703,70 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
701
703
 
702
704
  // Load and create secret obfuscator early so resumed session state and prompt warnings
703
705
  // reflect actual loaded secrets, not just the setting toggle.
706
+ // Env-based secrets are always collected (hardcoded masking — no opt-in required).
707
+ // File-based secrets (secrets.yml) remain opt-in behind the secrets.enabled setting.
704
708
  let obfuscator: SecretObfuscator | undefined;
705
- if (settings.get("secrets.enabled")) {
706
- const fileEntries = await logger.time("loadSecrets", loadSecrets, cwd, agentDir);
707
- const envEntries = collectEnvSecrets();
708
- const allEntries = [...envEntries, ...fileEntries];
709
+ {
710
+ // Collect profile-sensitive values (profile loads before session in main.ts).
711
+ let profileSensitiveValues: string[] | undefined;
712
+ try {
713
+ const { ProfileService } = await import("./services/f5xc-profile");
714
+ profileSensitiveValues = ProfileService.getSensitiveProfileValues();
715
+ } catch {
716
+ // ProfileService not initialized — skip (SDK consumers, tests, etc.)
717
+ }
718
+ // Scan both process.env AND bash.environment (profile-injected values)
719
+ // for env vars matching sensitive name patterns.
720
+ const bashEnv = (settings.get("bash.environment") ?? {}) as Record<string, string>;
721
+ const envEntries = collectEnvSecrets({
722
+ additionalEnv: bashEnv,
723
+ additionalValues: profileSensitiveValues,
724
+ });
725
+ let fileEntries: SecretEntry[] = [];
726
+ if (settings.get("secrets.enabled")) {
727
+ fileEntries = await logger.time("loadSecrets", loadSecrets, cwd, agentDir);
728
+ }
729
+ // File entries MUST come first to preserve placeholder index stability
730
+ // for resumed sessions that persisted #HASH# tokens from secrets.yml.
731
+ const allEntries = [...fileEntries, ...envEntries];
709
732
  if (allEntries.length > 0) {
710
733
  obfuscator = new SecretObfuscator(allEntries);
711
734
  }
712
735
  }
713
736
  const secretsEnabled = obfuscator?.hasSecrets() === true;
714
737
 
738
+ // Register profile-change listener to refresh obfuscator when /profile activate runs.
739
+ try {
740
+ const { ProfileService } = await import("./services/f5xc-profile");
741
+ ProfileService.onProfileChange(profile => {
742
+ const newValues: string[] = [profile.apiToken];
743
+ if (profile.sensitiveKeys && profile.env) {
744
+ for (const key of profile.sensitiveKeys) {
745
+ const v = profile.env[key];
746
+ if (v) newValues.push(v);
747
+ }
748
+ }
749
+ const bashEnv = (settings.get("bash.environment") ?? {}) as Record<string, string>;
750
+ for (const [name, value] of Object.entries(bashEnv)) {
751
+ if (value && SECRET_ENV_PATTERNS.test(name)) newValues.push(value);
752
+ }
753
+ if (obfuscator) {
754
+ obfuscator.addPlainSecrets(newValues);
755
+ } else {
756
+ // Obfuscator was undefined at session start (no secrets detected).
757
+ // Create one now so late profile activations are still masked.
758
+ const entries: SecretEntry[] = newValues
759
+ .filter(v => v.length > 0)
760
+ .map(v => ({ type: "plain" as const, content: v, mode: "obfuscate" as const }));
761
+ if (entries.length > 0) {
762
+ obfuscator = new SecretObfuscator(entries);
763
+ }
764
+ }
765
+ });
766
+ } catch {
767
+ // ProfileService not available — skip
768
+ }
769
+
715
770
  // Check if session has existing data to restore
716
771
  const existingSession = logger.time("loadSessionContext", () =>
717
772
  deobfuscateSessionContext(sessionManager.buildSessionContext(), obfuscator),
package/src/tools/bash.ts CHANGED
@@ -7,13 +7,14 @@ import type {
7
7
  } from "@f5xc-salesdemos/pi-agent-core";
8
8
  import type { Component } from "@f5xc-salesdemos/pi-tui";
9
9
  import { ImageProtocol, TERMINAL, Text } from "@f5xc-salesdemos/pi-tui";
10
- import { $env, getProjectDir, isEnoent, prompt } from "@f5xc-salesdemos/pi-utils";
10
+ import { $env, getProjectDir, isEnoent, prompt, setShellPwd } from "@f5xc-salesdemos/pi-utils";
11
11
  import { Type } from "@sinclair/typebox";
12
12
  import { type BashResult, executeBash } from "../exec/bash-executor";
13
13
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
14
14
  import { truncateToVisualLines } from "../modes/components/visual-truncate";
15
15
  import type { Theme } from "../modes/theme/theme";
16
16
  import bashDescription from "../prompts/tools/bash.md" with { type: "text" };
17
+ import { SECRET_ENV_PATTERNS, type SecretObfuscator } from "../secrets";
17
18
  import { DEFAULT_MAX_BYTES, TailBuffer } from "../session/streaming-output";
18
19
  import { renderStatusLine } from "../tui";
19
20
  import { CachedOutputBlock } from "../tui/output-block";
@@ -30,6 +31,9 @@ import { ToolAbortError, ToolError } from "./tool-errors";
30
31
  import { toolResult } from "./tool-result";
31
32
  import { clampTimeout } from "./tool-timeouts";
32
33
 
34
+ // Module-level obfuscator reference for the renderer (set by BashTool constructor).
35
+ let _sessionObfuscator: SecretObfuscator | undefined;
36
+
33
37
  export const BASH_DEFAULT_PREVIEW_LINES = 10;
34
38
 
35
39
  const BASH_ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -137,11 +141,20 @@ function escapeBashEnvValueForDisplay(value: string): string {
137
141
  .replaceAll("`", "\\`");
138
142
  }
139
143
 
140
- function formatBashEnvAssignments(env: Record<string, string> | undefined): string {
144
+ function formatBashEnvAssignments(
145
+ env: Record<string, string> | undefined,
146
+ obfuscator?: import("../secrets/obfuscator").SecretObfuscator,
147
+ ): string {
141
148
  if (!env || Object.keys(env).length === 0) return "";
142
149
  return Object.entries(env)
143
150
  .sort(([a], [b]) => a.localeCompare(b))
144
- .map(([key, value]) => `${key}="${escapeBashEnvValueForDisplay(value)}"`)
151
+ .map(([key, value]) => {
152
+ // Mask if name matches hardcoded patterns OR if the obfuscator recognizes the value as a secret.
153
+ const isSensitiveName = SECRET_ENV_PATTERNS.test(key);
154
+ const isSensitiveValue = obfuscator?.hasSecrets() && obfuscator.obfuscate(value) !== value;
155
+ const display = isSensitiveName || isSensitiveValue ? "***" : escapeBashEnvValueForDisplay(value);
156
+ return `${key}="${display}"`;
157
+ })
145
158
  .join(" ");
146
159
  }
147
160
 
@@ -241,6 +254,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
241
254
  readonly #autoBackgroundThresholdMs: number;
242
255
 
243
256
  constructor(private readonly session: ToolSession) {
257
+ _sessionObfuscator = session.obfuscator;
244
258
  this.#asyncEnabled = this.session.settings.get("async.enabled");
245
259
  this.#autoBackgroundEnabled = this.session.settings.get("bash.autoBackground.enabled");
246
260
  this.#autoBackgroundThresholdMs = Math.max(
@@ -338,6 +352,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
338
352
  headLines?: number;
339
353
  tailLines?: number;
340
354
  resolvedEnv?: Record<string, string>;
355
+ maskSecrets?: (text: string) => string;
341
356
  onUpdate?: AgentToolUpdateCallback<BashToolDetails>;
342
357
  startBackgrounded: boolean;
343
358
  }): ManagedBashJobHandle {
@@ -366,9 +381,11 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
366
381
  env: options.resolvedEnv,
367
382
  artifactPath,
368
383
  artifactId,
384
+ maskSecrets: options.maskSecrets,
369
385
  onChunk: chunk => {
370
386
  tailBuffer.append(chunk);
371
- latestText = tailBuffer.text();
387
+ const preview = options.maskSecrets ? options.maskSecrets(tailBuffer.text()) : tailBuffer.text();
388
+ latestText = preview;
372
389
  void reportProgress(latestText, { async: { state: "running", jobId, type: "bash" } });
373
390
  },
374
391
  });
@@ -539,6 +556,11 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
539
556
  const timeoutSec = clampTimeout("bash", rawTimeout);
540
557
  const timeoutMs = timeoutSec * 1000;
541
558
 
559
+ // Build secret masking callback from the session obfuscator (always-on for env secrets).
560
+ const obfuscator = this.session.obfuscator;
561
+ _sessionObfuscator = obfuscator; // Keep module-level ref fresh for renderer
562
+ const maskSecrets = obfuscator?.hasSecrets() ? (t: string) => obfuscator.obfuscate(t) : undefined;
563
+
542
564
  if (asyncRequested) {
543
565
  if (!this.session.asyncJobManager) {
544
566
  throw new ToolError("Async job manager unavailable for this session.");
@@ -551,6 +573,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
551
573
  headLines,
552
574
  tailLines,
553
575
  resolvedEnv,
576
+ maskSecrets,
554
577
  onUpdate,
555
578
  startBackgrounded: true,
556
579
  });
@@ -568,6 +591,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
568
591
  headLines,
569
592
  tailLines,
570
593
  resolvedEnv,
594
+ maskSecrets,
571
595
  onUpdate,
572
596
  startBackgrounded,
573
597
  });
@@ -608,6 +632,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
608
632
  env: resolvedEnv,
609
633
  artifactPath,
610
634
  artifactId,
635
+ maskSecrets,
611
636
  })
612
637
  : await executeBash(command, {
613
638
  cwd: commandCwd,
@@ -617,16 +642,25 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
617
642
  env: resolvedEnv,
618
643
  artifactPath,
619
644
  artifactId,
645
+ maskSecrets,
620
646
  onChunk: chunk => {
621
647
  tailBuffer.append(chunk);
622
648
  if (onUpdate) {
649
+ const preview = maskSecrets ? maskSecrets(tailBuffer.text()) : tailBuffer.text();
623
650
  onUpdate({
624
- content: [{ type: "text", text: tailBuffer.text() }],
651
+ content: [{ type: "text", text: preview }],
625
652
  details: {},
626
653
  });
627
654
  }
628
655
  },
629
656
  });
657
+ // Update working directory if the persistent shell changed it
658
+ if ("newCwd" in result && result.newCwd && result.newCwd !== this.session.cwd) {
659
+ this.session.cwd = result.newCwd;
660
+ setShellPwd(result.newCwd);
661
+ this.session.eventBus?.emit("cwd:changed", result.newCwd);
662
+ }
663
+
630
664
  if (result.cancelled) {
631
665
  if (signal?.aborted) {
632
666
  throw new ToolAbortError(normalizeResultOutput(result) || "Command aborted");
@@ -671,7 +705,9 @@ function formatBashCommand(args: BashRenderArgs): string {
671
705
  const prompt = "$";
672
706
  const cwd = getProjectDir();
673
707
  const displayWorkdir = formatToolWorkingDirectory(args.cwd, cwd);
674
- const renderedCommand = [formatBashEnvAssignments(getBashEnvForDisplay(args)), command].filter(Boolean).join(" ");
708
+ const renderedCommand = [formatBashEnvAssignments(getBashEnvForDisplay(args), _sessionObfuscator), command]
709
+ .filter(Boolean)
710
+ .join(" ");
675
711
  return displayWorkdir ? `${prompt} cd ${displayWorkdir} && ${renderedCommand}` : `${prompt} ${renderedCommand}`;
676
712
  }
677
713
 
package/src/tools/vim.ts CHANGED
@@ -644,7 +644,19 @@ export class VimTool implements AgentTool<typeof vimSchema, VimToolDetails> {
644
644
 
645
645
  await executeVimSteps(engine, steps, {
646
646
  pauseLastStep: params.pause === true,
647
- onKbdStep: emitUpdate ? () => emitUpdate() : undefined,
647
+ onKbdStep: emitUpdate
648
+ ? () => {
649
+ // Force update in prompt modes (command/search) so every keystroke
650
+ // is reported to onUpdate — users need to see each character of
651
+ // their ex-command input, and throttling can cause intermediate
652
+ // states to be skipped under heavy event-loop load (CI).
653
+ const forcePrompt =
654
+ engine.inputMode === "command" ||
655
+ engine.inputMode === "search-forward" ||
656
+ engine.inputMode === "search-backward";
657
+ return emitUpdate(forcePrompt);
658
+ }
659
+ : undefined,
648
660
  onInsertStep: emitUpdate ? () => emitUpdate(true) : undefined,
649
661
  });
650
662
 
@@ -71,8 +71,12 @@ export async function resolveProviderChain(
71
71
  const providers: SearchProvider[] = [];
72
72
 
73
73
  if (preferredProvider !== "auto") {
74
- if (await getSearchProvider(preferredProvider).isAvailable()) {
75
- providers.push(getSearchProvider(preferredProvider));
74
+ try {
75
+ if (await getSearchProvider(preferredProvider).isAvailable()) {
76
+ providers.push(getSearchProvider(preferredProvider));
77
+ }
78
+ } catch {
79
+ // Preferred provider check failed; continue with fallback chain
76
80
  }
77
81
  }
78
82
 
@@ -80,8 +84,12 @@ export async function resolveProviderChain(
80
84
  if (id === preferredProvider) continue;
81
85
 
82
86
  const provider = getSearchProvider(id);
83
- if (await provider.isAvailable()) {
84
- providers.push(provider);
87
+ try {
88
+ if (await provider.isAvailable()) {
89
+ providers.push(provider);
90
+ }
91
+ } catch {
92
+ // Provider availability check failed; skip and continue
85
93
  }
86
94
  }
87
95