@preapexis/pi-kit 1.1.2 → 1.2.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.
@@ -3,29 +3,21 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
3
 
4
4
  type LiteLlmModelInfo = {
5
5
  model_name?: string;
6
- litellm_params?: {
7
- model?: string;
8
- };
9
6
  model_info?: {
10
7
  id?: string;
11
8
  name?: string;
12
9
  display_name?: string;
13
- description?: string;
14
10
  max_tokens?: number;
15
11
  max_input_tokens?: number;
16
12
  context_window?: number;
17
13
  input_cost_per_token?: number;
18
14
  output_cost_per_token?: number;
19
15
  supports_vision?: boolean;
20
- supports_function_calling?: boolean;
21
- mode?: string;
22
16
  };
23
17
  };
24
18
 
25
19
  type OpenAiModel = {
26
20
  id?: string;
27
- object?: string;
28
- owned_by?: string;
29
21
  };
30
22
 
31
23
  type ModelPayload = {
@@ -45,10 +37,6 @@ type PiModel = {
45
37
  };
46
38
  contextWindow: number;
47
39
  maxTokens: number;
48
- compat?: {
49
- supportsDeveloperRole?: boolean;
50
- supportsReasoningEffort?: boolean;
51
- };
52
40
  };
53
41
 
54
42
  const PROVIDER_ID = "litellm";
@@ -59,9 +47,13 @@ export default async function (pi: ExtensionAPI): Promise<void> {
59
47
  process.env.LITELLM_BASE_URL ?? DEFAULT_BASE_URL
60
48
  );
61
49
 
62
- async function registerLiteLlmProvider(): Promise<void> {
50
+ async function registerLiteLlmProvider(): Promise<number> {
63
51
  const models = await discoverModels(baseUrl);
64
52
 
53
+ if (models.length === 0) {
54
+ return 0;
55
+ }
56
+
65
57
  pi.registerProvider(PROVIDER_ID, {
66
58
  name: "LiteLLM",
67
59
  baseUrl,
@@ -69,20 +61,54 @@ export default async function (pi: ExtensionAPI): Promise<void> {
69
61
  api: "openai-completions",
70
62
  models
71
63
  });
64
+
65
+ return models.length;
72
66
  }
73
67
 
74
- await registerLiteLlmProvider();
68
+ try {
69
+ const count = await registerLiteLlmProvider();
70
+
71
+ if (count > 0) {
72
+ console.log(`[litellm] Registered ${count} models from ${baseUrl}`);
73
+ } else {
74
+ console.log("[litellm] No models found. Provider was not registered.");
75
+ }
76
+ } catch (error) {
77
+ console.log(
78
+ [
79
+ "[litellm] Provider skipped.",
80
+ error instanceof Error ? error.message : String(error),
81
+ "Start LiteLLM and run /litellm-refresh."
82
+ ].join("\n")
83
+ );
84
+ }
75
85
 
76
86
  pi.registerCommand("litellm-refresh", {
77
87
  description: "Refresh LiteLLM models from the LiteLLM proxy",
78
88
  handler: async (_args, ctx) => {
79
89
  try {
80
- await registerLiteLlmProvider();
90
+ const count = await registerLiteLlmProvider();
91
+
92
+ if (count === 0) {
93
+ ctx.ui.notify(
94
+ [
95
+ "LiteLLM refresh finished, but no models were found.",
96
+ "",
97
+ `Base URL: ${baseUrl}`,
98
+ "",
99
+ "Make sure LiteLLM is running."
100
+ ].join("\n"),
101
+ "warning"
102
+ );
103
+
104
+ return;
105
+ }
81
106
 
82
107
  ctx.ui.notify(
83
108
  [
84
109
  "LiteLLM models refreshed.",
85
110
  "",
111
+ `Models found: ${count}`,
86
112
  `Base URL: ${baseUrl}`,
87
113
  "",
88
114
  "Run /model to select a LiteLLM model."
@@ -129,26 +155,11 @@ async function discoverModels(baseUrl: string): Promise<PiModel[]> {
129
155
  return models;
130
156
  }
131
157
  } catch {
132
- // Try the next endpoint.
158
+ // Try next endpoint.
133
159
  }
134
160
  }
135
161
 
136
- const fallbackModels = getFallbackModels();
137
-
138
- if (fallbackModels.length > 0) {
139
- return fallbackModels;
140
- }
141
-
142
- throw new Error(
143
- [
144
- "Could not discover LiteLLM models.",
145
- "",
146
- `Tried: ${endpoints.join(", ")}`,
147
- "",
148
- "Make sure LiteLLM is running and set LITELLM_API_KEY if your proxy requires auth.",
149
- "You can also set LITELLM_MODELS as a comma-separated fallback."
150
- ].join("\n")
151
- );
162
+ return getFallbackModels();
152
163
  }
153
164
 
154
165
  async function fetchModelPayload(url: string): Promise<ModelPayload> {
@@ -228,11 +239,7 @@ function modelFromPayloadItem(
228
239
  cacheWrite: 0
229
240
  },
230
241
  contextWindow,
231
- maxTokens,
232
- compat: {
233
- supportsDeveloperRole: false,
234
- supportsReasoningEffort: false
235
- }
242
+ maxTokens
236
243
  };
237
244
  }
238
245
 
@@ -293,10 +300,6 @@ function getFallbackModels(): PiModel[] {
293
300
  cacheWrite: 0
294
301
  },
295
302
  contextWindow: 128000,
296
- maxTokens: 4096,
297
- compat: {
298
- supportsDeveloperRole: false,
299
- supportsReasoningEffort: false
300
- }
303
+ maxTokens: 4096
301
304
  }));
302
305
  }
@@ -0,0 +1,234 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import * as path from "node:path";
3
+
4
+ type EventContext = Parameters<Parameters<ExtensionAPI["on"]>[1]>[1];
5
+
6
+ type ToolDecision =
7
+ | {
8
+ block: true;
9
+ reason: string;
10
+ }
11
+ | undefined;
12
+
13
+ type InputRecord = Record<string, unknown>;
14
+
15
+ const STATUS_KEY = "workspace-guard";
16
+
17
+ export default function (pi: ExtensionAPI): void {
18
+ let workspaceRoot: string | null = null;
19
+
20
+ function normalize(filePath: string): string {
21
+ return path.resolve(filePath);
22
+ }
23
+
24
+ function isInsideWorkspace(targetPath: string): boolean {
25
+ if (!workspaceRoot) return true;
26
+
27
+ const root = normalize(workspaceRoot);
28
+ const target = normalize(targetPath);
29
+
30
+ return target === root || target.startsWith(root + path.sep);
31
+ }
32
+
33
+ function inputRecord(input: unknown): InputRecord {
34
+ if (input && typeof input === "object") {
35
+ return input as InputRecord;
36
+ }
37
+
38
+ return {};
39
+ }
40
+
41
+ function getPathValue(input: InputRecord): string | undefined {
42
+ const keys = [
43
+ "path",
44
+ "filePath",
45
+ "filepath",
46
+ "dir",
47
+ "directory",
48
+ "cwd",
49
+ "root"
50
+ ];
51
+
52
+ for (const key of keys) {
53
+ const value = input[key];
54
+
55
+ if (typeof value === "string" && value.trim()) {
56
+ return value;
57
+ }
58
+ }
59
+
60
+ return undefined;
61
+ }
62
+
63
+ function resolveFromWorkspaceOrCwd(ctx: EventContext, value: string): string {
64
+ if (path.isAbsolute(value)) {
65
+ return normalize(value);
66
+ }
67
+
68
+ return normalize(path.join(ctx.cwd, value));
69
+ }
70
+
71
+ function commandLooksOutside(command: string): boolean {
72
+ const patterns = [
73
+ /\bcd\s+\.\./i,
74
+ /\bcd\s+["']?\//i,
75
+ /\bcd\s+["']?[a-zA-Z]:\\/i,
76
+ /\bpushd\s+\.\./i,
77
+ /\bpushd\s+["']?\//i,
78
+ /\bpushd\s+["']?[a-zA-Z]:\\/i,
79
+ /\bgit\s+-C\s+\.\./i,
80
+ /\bnpm\s+--prefix\s+\.\./i,
81
+ /\bpnpm\s+-C\s+\.\./i,
82
+ /\byarn\s+--cwd\s+\.\./i,
83
+ /\bSet-Location\s+\.\./i,
84
+ /\bsl\s+\.\./i
85
+ ];
86
+
87
+ return patterns.some((pattern) => pattern.test(command));
88
+ }
89
+
90
+ async function confirmOutsideAccess(
91
+ ctx: EventContext,
92
+ action: string,
93
+ targetPath: string
94
+ ): Promise<ToolDecision> {
95
+ if (!workspaceRoot) return undefined;
96
+
97
+ if (isInsideWorkspace(targetPath)) {
98
+ return undefined;
99
+ }
100
+
101
+ const message = [
102
+ `Workspace guard blocked outside-${action} access.`,
103
+ "",
104
+ `Workspace root: ${workspaceRoot}`,
105
+ `Requested path: ${targetPath}`,
106
+ "",
107
+ "Allow this one time?"
108
+ ].join("\n");
109
+
110
+ if (!ctx.hasUI) {
111
+ return {
112
+ block: true,
113
+ reason: `Outside workspace ${action} blocked: ${targetPath}`
114
+ };
115
+ }
116
+
117
+ const ok = await ctx.ui.confirm("Outside workspace access", message);
118
+
119
+ if (!ok) {
120
+ return {
121
+ block: true,
122
+ reason: `Outside workspace ${action} cancelled by user.`
123
+ };
124
+ }
125
+
126
+ return undefined;
127
+ }
128
+
129
+ pi.on("session_start", async (_event, ctx) => {
130
+ workspaceRoot = normalize(ctx.cwd);
131
+
132
+ if (ctx.hasUI) {
133
+ ctx.ui.setStatus(STATUS_KEY, "workspace: locked");
134
+ }
135
+ });
136
+
137
+ pi.on("before_agent_start", async (event) => {
138
+ return {
139
+ systemPrompt:
140
+ event.systemPrompt +
141
+ `
142
+
143
+ Workspace boundary rules:
144
+ - Treat the current working directory as the workspace root.
145
+ - Do not read, search, edit, write, delete, or inspect files outside the current workspace unless the user explicitly asks.
146
+ - Before using any path outside the workspace, ask the user for permission.
147
+ - Prefer relative paths inside the current repository.
148
+ - Do not run commands that cd, pushd, Set-Location, or otherwise move outside the workspace unless explicitly requested.
149
+ - If outside-workspace context seems useful, ask first instead of checking it silently.
150
+ `
151
+ };
152
+ });
153
+
154
+ pi.on("tool_call", async (event, ctx) => {
155
+ if (!workspaceRoot) {
156
+ workspaceRoot = normalize(ctx.cwd);
157
+ }
158
+
159
+ const input = inputRecord(event.input);
160
+
161
+ if (
162
+ event.toolName === "read" ||
163
+ event.toolName === "write" ||
164
+ event.toolName === "edit" ||
165
+ event.toolName === "ls" ||
166
+ event.toolName === "grep" ||
167
+ event.toolName === "find"
168
+ ) {
169
+ const rawPath = getPathValue(input);
170
+
171
+ if (rawPath) {
172
+ const targetPath = resolveFromWorkspaceOrCwd(ctx, rawPath);
173
+
174
+ return await confirmOutsideAccess(ctx, event.toolName, targetPath);
175
+ }
176
+ }
177
+
178
+ if (event.toolName === "bash") {
179
+ const command = String(input.command ?? "");
180
+
181
+ const rawCwd = typeof input.cwd === "string" ? input.cwd : undefined;
182
+
183
+ if (rawCwd) {
184
+ const targetCwd = resolveFromWorkspaceOrCwd(ctx, rawCwd);
185
+ const decision = await confirmOutsideAccess(ctx, "command", targetCwd);
186
+
187
+ if (decision) return decision;
188
+ }
189
+
190
+ if (commandLooksOutside(command)) {
191
+ if (!ctx.hasUI) {
192
+ return {
193
+ block: true,
194
+ reason: "Command that may leave the workspace was blocked."
195
+ };
196
+ }
197
+
198
+ const ok = await ctx.ui.confirm(
199
+ "Command may leave workspace",
200
+ [
201
+ "This command appears to move outside the current workspace.",
202
+ "",
203
+ `Workspace root: ${workspaceRoot}`,
204
+ "",
205
+ command,
206
+ "",
207
+ "Allow this one time?"
208
+ ].join("\n")
209
+ );
210
+
211
+ if (!ok) {
212
+ return {
213
+ block: true,
214
+ reason: "Outside-workspace command cancelled by user."
215
+ };
216
+ }
217
+ }
218
+ }
219
+
220
+ return undefined;
221
+ });
222
+
223
+ pi.registerCommand("workspace-root", {
224
+ description: "Show the current locked workspace root",
225
+ handler: async (_args, ctx) => {
226
+ if (!ctx.hasUI) return;
227
+
228
+ ctx.ui.notify(
229
+ `Workspace root:\n\n${workspaceRoot ?? normalize(ctx.cwd)}`,
230
+ "info"
231
+ );
232
+ }
233
+ });
234
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preapexis/pi-kit",
3
- "version": "1.1.2",
3
+ "version": "1.2.0",
4
4
  "description": "Personal Pi coding-agent kit with safety extensions, status UI, prompt workflows, skills, and themes.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -29,7 +29,7 @@
29
29
  "thinkingText": "muted",
30
30
 
31
31
  "selectedBg": "panel2",
32
- "userMessageBg": "panel",
32
+ "userMessageBg": "panel2",
33
33
  "userMessageText": "",
34
34
  "customMessageBg": "#e0f2fe",
35
35
  "customMessageText": "",
@@ -29,7 +29,7 @@
29
29
  "thinkingText": "muted",
30
30
 
31
31
  "selectedBg": "panel2",
32
- "userMessageBg": "panel",
32
+ "userMessageBg": "#1e293b",
33
33
  "userMessageText": "",
34
34
  "customMessageBg": "#0e7490",
35
35
  "customMessageText": "",
@@ -27,7 +27,7 @@
27
27
  "thinkingText": "muted",
28
28
 
29
29
  "selectedBg": "panel",
30
- "userMessageBg": "panel",
30
+ "userMessageBg": "soft",
31
31
  "userMessageText": "",
32
32
  "customMessageBg": "panel",
33
33
  "customMessageText": "",
@@ -29,7 +29,7 @@
29
29
  "thinkingText": "muted",
30
30
 
31
31
  "selectedBg": "panel2",
32
- "userMessageBg": "panel",
32
+ "userMessageBg": "panel2",
33
33
  "userMessageText": "",
34
34
  "customMessageBg": "panel",
35
35
  "customMessageText": "",