@f5xc-salesdemos/xcsh 17.0.1 → 17.0.2
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 +7 -7
- package/src/config/model-registry.ts +47 -2
- package/src/config/settings-schema.ts +14 -14
- package/src/modes/controllers/selector-controller.ts +119 -6
- package/src/sdk.ts +59 -4
- package/src/tools/bash.ts +42 -6
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5xc-salesdemos/xcsh",
|
|
4
|
-
"version": "17.0.
|
|
4
|
+
"version": "17.0.2",
|
|
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.
|
|
50
|
-
"@f5xc-salesdemos/pi-agent-core": "17.0.
|
|
51
|
-
"@f5xc-salesdemos/pi-ai": "17.0.
|
|
52
|
-
"@f5xc-salesdemos/pi-natives": "17.0.
|
|
53
|
-
"@f5xc-salesdemos/pi-tui": "17.0.
|
|
54
|
-
"@f5xc-salesdemos/pi-utils": "17.0.
|
|
49
|
+
"@f5xc-salesdemos/xcsh-stats": "17.0.2",
|
|
50
|
+
"@f5xc-salesdemos/pi-agent-core": "17.0.2",
|
|
51
|
+
"@f5xc-salesdemos/pi-ai": "17.0.2",
|
|
52
|
+
"@f5xc-salesdemos/pi-natives": "17.0.2",
|
|
53
|
+
"@f5xc-salesdemos/pi-tui": "17.0.2",
|
|
54
|
+
"@f5xc-salesdemos/pi-utils": "17.0.2",
|
|
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
|
-
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
264
|
-
|
|
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
|
-
|
|
272
|
-
|
|
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
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
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(
|
|
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]) =>
|
|
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
|
-
|
|
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:
|
|
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]
|
|
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
|
|