pi-ui-extend 1.0.13 → 1.0.15
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/README.md +1 -1
- package/dist/app/session/session-event-controller.d.ts +9 -0
- package/dist/app/session/session-event-controller.js +25 -2
- package/dist/schemas/pi-tools-suite-schema.d.ts +7 -0
- package/dist/schemas/pi-tools-suite-schema.js +17 -0
- package/external/pi-tools-suite/README.md +16 -1
- package/external/pi-tools-suite/src/config.ts +26 -1
- package/external/pi-tools-suite/src/credential-firewall/config.ts +77 -0
- package/external/pi-tools-suite/src/credential-firewall/index.ts +62 -0
- package/external/pi-tools-suite/src/credential-firewall/redact.ts +222 -0
- package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +13 -0
- package/external/pi-tools-suite/src/index.ts +2 -0
- package/external/pi-tools-suite/src/todo/index.ts +55 -3
- package/package.json +1 -1
- package/schemas/pi-tools-suite.json +71 -0
package/README.md
CHANGED
|
@@ -34,7 +34,7 @@ Pi provides the agent runtime, models, tools, skills, extensions, and persistent
|
|
|
34
34
|
- **Keep projects organized.** Tabs are scoped to the working directory and survive restarts. Search, resume, fork, clone, jump through, import, export, share, or delete sessions without leaving the terminal.
|
|
35
35
|
- **Stay in flow.** Run quick commands with `!`, open a raw interactive terminal with `!!`, paste images, follow file links, dictate in English or Russian, and improve a prompt before sending it.
|
|
36
36
|
- **Know what the agent is doing.** The status area exposes model, thinking level, context, usage, workspace, todos, sub-agents, voice state, and prompt actions — with mouse targets where useful.
|
|
37
|
-
- **Bring a serious toolkit.** Pix ships with `pi-tools-suite`:
|
|
37
|
+
- **Bring a serious toolkit.** Pix ships with `pi-tools-suite`: 18 integrated modules for indexed repository discovery, AST edits, LSP diagnostics, parallel agents, durable todos, context compression, web access, provider accounts, and more.
|
|
38
38
|
- **Use the models you want.** Pix runs on Pi's provider ecosystem and supports model switching, scoped model lists, per-model thinking levels, usage views, autocomplete, and fallback-aware helper workflows.
|
|
39
39
|
|
|
40
40
|
Pix is not a separate agent protocol or an RPC wrapper around Pi. It runs on the Pi SDK, so the runtime, session format, extensions, skills, prompts, and tools remain part of the same ecosystem.
|
|
@@ -125,6 +125,15 @@ export declare class AppSessionEventController {
|
|
|
125
125
|
}): Promise<boolean>;
|
|
126
126
|
handleSessionEvent(event: AgentSessionEvent): void;
|
|
127
127
|
private retryFailedTurn;
|
|
128
|
+
/**
|
|
129
|
+
* The SDK only emits `auto_retry_end` (whose toast carries the Retry button)
|
|
130
|
+
* when auto-retry actually runs. When retry is disabled, a transient streaming
|
|
131
|
+
* error such as a 429 rate limit surfaces only as the error entry above, with
|
|
132
|
+
* no way to resume the failed turn. Offer a manual Retry toast in that case,
|
|
133
|
+
* mirroring the auto-retry path. User aborts and non-retryable errors are
|
|
134
|
+
* excluded, and retry being enabled means the auto-retry toast will handle it.
|
|
135
|
+
*/
|
|
136
|
+
private showManualRetryOnTerminalError;
|
|
128
137
|
private retryFailedTurnAsync;
|
|
129
138
|
private isActiveRuntimeSession;
|
|
130
139
|
addCustomMessageEntry(message: Record<string, unknown>): void;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isRetryableAssistantError } from "@earendil-works/pi-ai";
|
|
1
2
|
import { createId } from "../id.js";
|
|
2
3
|
import { extractImageContents, renderContent, renderUserMessageContent, stringifyUnknown } from "../rendering/message-content.js";
|
|
3
4
|
import { customMessageEntry, extensionSessionEntry, loadSessionHistoryEntries, loadSessionHistoryEntriesAsync } from "./session-history.js";
|
|
@@ -351,6 +352,25 @@ export class AppSessionEventController {
|
|
|
351
352
|
retryFailedTurn(session) {
|
|
352
353
|
void this.retryFailedTurnAsync(session);
|
|
353
354
|
}
|
|
355
|
+
/**
|
|
356
|
+
* The SDK only emits `auto_retry_end` (whose toast carries the Retry button)
|
|
357
|
+
* when auto-retry actually runs. When retry is disabled, a transient streaming
|
|
358
|
+
* error such as a 429 rate limit surfaces only as the error entry above, with
|
|
359
|
+
* no way to resume the failed turn. Offer a manual Retry toast in that case,
|
|
360
|
+
* mirroring the auto-retry path. User aborts and non-retryable errors are
|
|
361
|
+
* excluded, and retry being enabled means the auto-retry toast will handle it.
|
|
362
|
+
*/
|
|
363
|
+
showManualRetryOnTerminalError(error) {
|
|
364
|
+
const errorText = error.errorMessage;
|
|
365
|
+
if (!errorText || !isRetryableAssistantError(error))
|
|
366
|
+
return;
|
|
367
|
+
const session = this.host.runtime()?.session;
|
|
368
|
+
if (!session || session.autoRetryEnabled)
|
|
369
|
+
return;
|
|
370
|
+
this.host.showToast(`Request failed: ${errorText}`, "error", {
|
|
371
|
+
action: { label: "Retry", onSelect: () => this.retryFailedTurn(session) },
|
|
372
|
+
});
|
|
373
|
+
}
|
|
354
374
|
async retryFailedTurnAsync(session) {
|
|
355
375
|
const runtime = this.host.runtime();
|
|
356
376
|
if (!runtime || !this.isActiveRuntimeSession(runtime, session))
|
|
@@ -693,12 +713,15 @@ export class AppSessionEventController {
|
|
|
693
713
|
this.assistantMessageClosed = true;
|
|
694
714
|
this.host.setSessionActivity(this.host.runtime()?.session.isStreaming ? "running" : "idle");
|
|
695
715
|
break;
|
|
696
|
-
case "error":
|
|
716
|
+
case "error": {
|
|
697
717
|
this.finishCurrentThinkingEntry();
|
|
698
718
|
this.flushAssistantTextBuffer(true);
|
|
699
719
|
this.host.setSessionActivity(this.host.runtime()?.session.isStreaming ? "running" : "idle");
|
|
700
|
-
|
|
720
|
+
const errorText = assistantEvent.error.errorMessage ?? assistantEvent.reason;
|
|
721
|
+
this.addEntry({ id: createId("error"), kind: "error", text: errorText });
|
|
722
|
+
this.showManualRetryOnTerminalError(assistantEvent.error);
|
|
701
723
|
break;
|
|
724
|
+
}
|
|
702
725
|
default:
|
|
703
726
|
break;
|
|
704
727
|
}
|
|
@@ -9,7 +9,10 @@ export declare const PiToolsSuiteConfigSchema: Type.TObject<{
|
|
|
9
9
|
$schema: Type.TOptional<Type.TString>;
|
|
10
10
|
enabled: Type.TOptional<Type.TBoolean>;
|
|
11
11
|
disabledModules: Type.TOptional<Type.TArray<Type.TString>>;
|
|
12
|
+
enabledModules: Type.TOptional<Type.TArray<Type.TString>>;
|
|
13
|
+
modules: Type.TOptional<Type.TRecord<"^.*$", Type.TBoolean>>;
|
|
12
14
|
todoThinking: Type.TOptional<Type.TBoolean>;
|
|
15
|
+
todoThinkingOverrides: Type.TOptional<Type.TRecord<"^.*$", Type.TUnion<[Type.TLiteral<"off">, Type.TLiteral<"minimal">, Type.TLiteral<"low">, Type.TLiteral<"medium">, Type.TLiteral<"high">, Type.TLiteral<"xhigh">, Type.TLiteral<"max">, Type.TNull]>>>;
|
|
13
16
|
lookupModel: Type.TOptional<Type.TUnion<[Type.TString, Type.TNull]>>;
|
|
14
17
|
terminalBell: Type.TOptional<Type.TObject<{
|
|
15
18
|
sound: Type.TOptional<Type.TBoolean>;
|
|
@@ -163,6 +166,10 @@ export declare const PiToolsSuiteConfigSchema: Type.TObject<{
|
|
|
163
166
|
prompt: Type.TString;
|
|
164
167
|
}>>>;
|
|
165
168
|
}>>;
|
|
169
|
+
secretFirewall: Type.TOptional<Type.TObject<{
|
|
170
|
+
sessionHygiene: Type.TOptional<Type.TBoolean>;
|
|
171
|
+
notify: Type.TOptional<Type.TBoolean>;
|
|
172
|
+
}>>;
|
|
166
173
|
lsp: Type.TOptional<Type.TObject<{
|
|
167
174
|
servers: Type.TOptional<Type.TArray<Type.TObject<{
|
|
168
175
|
id: Type.TString;
|
|
@@ -181,6 +181,10 @@ const PromptCommand = Type.Object({
|
|
|
181
181
|
const PromptCommandsConfig = Type.Object({
|
|
182
182
|
commands: Type.Optional(Type.Record(Type.String(), PromptCommand, { description: "Command definitions keyed by slash command name." })),
|
|
183
183
|
}, { description: "User-defined slash commands." });
|
|
184
|
+
const SecretFirewallConfig = Type.Object({
|
|
185
|
+
sessionHygiene: Type.Optional(Type.Boolean({ description: "Redact detected secret material from tool results and completed messages before it remains in session history." })),
|
|
186
|
+
notify: Type.Optional(Type.Boolean({ description: "Show a warning when one or more secrets are redacted. Secret values are never included in notifications." })),
|
|
187
|
+
}, { description: "Settings for the opt-in credential-firewall module." });
|
|
184
188
|
// ---------------------------------------------------------------------------
|
|
185
189
|
// LSP
|
|
186
190
|
// ---------------------------------------------------------------------------
|
|
@@ -214,13 +218,26 @@ export const PiToolsSuiteConfigSchema = Type.Object({
|
|
|
214
218
|
$schema: Type.Optional(Type.String({ description: "JSON Schema URL used by editors for validation and autocomplete." })),
|
|
215
219
|
enabled: Type.Optional(Type.Boolean({ description: "Enable or disable the entire pi-tools-suite extension." })),
|
|
216
220
|
disabledModules: Type.Optional(Type.Array(Type.String(), { description: "List of disabled module names (e.g. ['lsp', 'prompt-commands'])." })),
|
|
221
|
+
enabledModules: Type.Optional(Type.Array(Type.String(), { description: "List of module names to explicitly enable, including modules that are disabled by default." })),
|
|
222
|
+
modules: Type.Optional(Type.Record(Type.String(), Type.Boolean(), { description: "Per-module enable/disable map. credential-firewall is disabled by default and can be enabled here." })),
|
|
217
223
|
todoThinking: Type.Optional(Type.Boolean({ description: "Enable per-todo thinking levels and automatic thinking switch/restore when tasks become in-progress/completed." })),
|
|
224
|
+
todoThinkingOverrides: Type.Optional(Type.Record(Type.String(), Type.Union([
|
|
225
|
+
Type.Literal("off"),
|
|
226
|
+
Type.Literal("minimal"),
|
|
227
|
+
Type.Literal("low"),
|
|
228
|
+
Type.Literal("medium"),
|
|
229
|
+
Type.Literal("high"),
|
|
230
|
+
Type.Literal("xhigh"),
|
|
231
|
+
Type.Literal("max"),
|
|
232
|
+
Type.Null(),
|
|
233
|
+
]), { description: "Force per-todo thinking for matching provider/model or bare-model keys. Keys support * and ? wildcards; null removes an inherited override." })),
|
|
218
234
|
lookupModel: Type.Optional(Type.Union([Type.String(), Type.Null()], { description: "Vision-capable provider/model used by GLM's lookup tool; unset or null disables lookup." })),
|
|
219
235
|
terminalBell: Type.Optional(TerminalBellConfig),
|
|
220
236
|
dcp: Type.Optional(DcpConfig),
|
|
221
237
|
asyncSubagents: Type.Optional(AsyncSubagentsConfig),
|
|
222
238
|
toolRenderer: Type.Optional(ToolRendererConfig),
|
|
223
239
|
promptCommands: Type.Optional(PromptCommandsConfig),
|
|
240
|
+
secretFirewall: Type.Optional(SecretFirewallConfig),
|
|
224
241
|
lsp: Type.Optional(LspConfig),
|
|
225
242
|
}, {
|
|
226
243
|
$id: "https://unpkg.com/pi-ui-extend/schemas/pi-tools-suite.json",
|
|
@@ -5,6 +5,7 @@ Local all-in-one Pi extension package.
|
|
|
5
5
|
This package keeps shared Pi tools as ordinary source folders under `src/` and registers them through one entrypoint.
|
|
6
6
|
|
|
7
7
|
- `src/coding-discipline` — injects a deduplicated silent-mode and quality-discipline block at the very top of the main-session per-turn system prompt for GLM main-session models only (`isGlmModel`) immediately before the LLM request; non-GLM models are left untouched; disabled for async sub-agents
|
|
8
|
+
- `src/credential-firewall` — opt-in secret firewall for high-confidence outbound/session credential redaction; disabled by default
|
|
8
9
|
- `src/ast-grep` — `ast_grep` / `ast_apply`
|
|
9
10
|
- `src/async-subagents` — `subagents` tool and sub-agent slash commands, including oh-my-openagent-style `/ultrawork` (`/ulw`) and `/hyperplan` orchestration prompts, plus config-defined sub-agent model/thinking/args presets selected via `/subagent-preset` from `asyncSubagents` in `~/.config/pi/pi-tools-suite.jsonc`; includes the `frontend` profile for Gemini-friendly UI/UX and visual frontend work and the `oracle` profile for cross-provider second opinions; enforces a 30-minute per-agent execution timeout, project-wide `maxConcurrent` queueing, optional retry/backoff, and `result.json` structured metadata/chaining fields next to raw `result.md`; stores project-local run files and a registry under `.pi/subagents/` so result/status collection can recover after compaction or reload while the main session remains alive
|
|
10
11
|
- `src/lsp` — shared LSP diagnostics hook/library that enriches mutating tool results with diagnostics and shuts down language servers on session shutdown
|
|
@@ -23,7 +24,7 @@ This package keeps shared Pi tools as ordinary source folders under `src/` and r
|
|
|
23
24
|
|
|
24
25
|
`index.ts` is intentionally only a thin auto-discovery shim that re-exports `src/index.ts`. There is no `pi.extensions` manifest here, so local Pi auto-discovery loads the suite once via `~/.pi/agent/extensions/pi-tools-suite/index.ts` and does not double-register tools.
|
|
25
26
|
|
|
26
|
-
Registration order is preserved in `src/index.ts`: coding-discipline, ast-grep, async-subagents, lsp, comment-checker, session-name, repo-discovery command/tool gate, antigravity-auth provider, OpenCode import, todo, model-tools, usage, web-search, dcp, prompt-commands, then
|
|
27
|
+
Registration order is preserved in `src/index.ts`: coding-discipline, ast-grep, async-subagents, lsp, comment-checker, session-name, repo-discovery command/tool gate, antigravity-auth provider, OpenCode import, todo, model-tools, usage, web-search, dcp, prompt-commands, skill-installer, credential-firewall, then codex-reasoning-fix. Tool metadata and active model-specific tool sets have two modes: standard and repo-aware. When `.indexer-cli` enables `repo_*`, those tools stay active ahead of overlapping lower-level aliases so the indexed discovery surface has priority.
|
|
27
28
|
|
|
28
29
|
## Disabling modules
|
|
29
30
|
|
|
@@ -44,6 +45,8 @@ PI_TOOLS_SUITE_DISABLED=1 pi ... # disables all pi-tools-suite modules
|
|
|
44
45
|
|
|
45
46
|
`disabledExtensions`, `enabledModules`, `enabledExtensions`, and an `extensions` map are accepted as aliases for the same module names. Use `*` or `all` in `PI_TOOLS_SUITE_DISABLED_MODULES` to skip every registered module.
|
|
46
47
|
|
|
48
|
+
`credential-firewall` is disabled by default. Enable it explicitly with `"modules": { "credential-firewall": true }`. When enabled it replaces high-confidence secret material in the final provider payload with stable placeholders such as `<SECRET:github_token:1>`. `secretFirewall.sessionHygiene` (default `true`) applies the same redactor to tool results and completed messages before they remain in session history; `secretFirewall.notify` controls warnings. Entropy-only detection is intentionally not used yet to avoid corrupting hashes, IDs, minified assets, and other high-entropy non-secrets.
|
|
49
|
+
|
|
47
50
|
Saved prompt slash commands are stored under `promptCommands`. Use `/prompt-commands` to create, edit, rename, delete, list, show the config path, or run them from an interactive menu. After a CRUD edit the module reloads Pi resources so the slash-command list reflects the config. Each saved command sends its saved prompt as a user message.
|
|
48
51
|
|
|
49
52
|
```jsonc
|
|
@@ -59,6 +62,18 @@ Saved prompt slash commands are stored under `promptCommands`. Use `/prompt-comm
|
|
|
59
62
|
}
|
|
60
63
|
```
|
|
61
64
|
|
|
65
|
+
Todo thinking can be enabled globally and forced to a fixed level for selected models. `todoThinkingOverrides` keys accept exact `provider/model` or bare-model names plus `*` and `?` wildcards. Full provider/model matches beat bare-model matches, exact matches beat wildcards, and the more specific wildcard wins. The override is applied at runtime to create/update and batch create/update mutations even when the model requests another level or omits `thinking`. Unsupported levels are normalized to the nearest level supported by the current model. Later config layers can remove an inherited entry with `null`.
|
|
66
|
+
|
|
67
|
+
```jsonc
|
|
68
|
+
{
|
|
69
|
+
"todoThinking": true,
|
|
70
|
+
"todoThinkingOverrides": {
|
|
71
|
+
"zai/glm-5.3": "max",
|
|
72
|
+
"cheap-provider/*": "high"
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
62
77
|
DCP settings are stored only under `dcp` in the user shared config file `~/.config/pi/pi-tools-suite.jsonc`. Legacy standalone `dcp.jsonc`, `$PI_CONFIG_DIR`, and project-local `.pi/pi-tools-suite.jsonc` DCP settings are intentionally ignored by the ported headless DCP module.
|
|
63
78
|
|
|
64
79
|
```jsonc
|
|
@@ -9,6 +9,7 @@ export interface PiToolsSuiteConfig {
|
|
|
9
9
|
enabled: boolean;
|
|
10
10
|
disabledModules: string[];
|
|
11
11
|
todoThinking: boolean;
|
|
12
|
+
todoThinkingOverrides: Record<string, TodoThinkingLevel>;
|
|
12
13
|
/** Vision-capable model used by the coding-discipline lookup tool; unset disables lookup. */
|
|
13
14
|
lookupModel?: string;
|
|
14
15
|
/**
|
|
@@ -25,6 +26,7 @@ type MutableConfig = {
|
|
|
25
26
|
enabled: boolean;
|
|
26
27
|
disabledModules: Set<string>;
|
|
27
28
|
todoThinking: boolean;
|
|
29
|
+
todoThinkingOverrides: Map<string, TodoThinkingLevel>;
|
|
28
30
|
lookupModel: string | undefined;
|
|
29
31
|
codingDisciplineStrictness: CodingDisciplineStrictness;
|
|
30
32
|
};
|
|
@@ -32,6 +34,8 @@ type MutableConfig = {
|
|
|
32
34
|
export const CODING_DISCIPLINE_STRICTNESS_VALUES = ["strict", "lenient"] as const;
|
|
33
35
|
export type CodingDisciplineStrictness = (typeof CODING_DISCIPLINE_STRICTNESS_VALUES)[number];
|
|
34
36
|
export const DEFAULT_CODING_DISCIPLINE_STRICTNESS: CodingDisciplineStrictness = "lenient";
|
|
37
|
+
const TODO_THINKING_OVERRIDE_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
38
|
+
export type TodoThinkingLevel = (typeof TODO_THINKING_OVERRIDE_LEVELS)[number];
|
|
35
39
|
|
|
36
40
|
type Env = Record<string, string | undefined>;
|
|
37
41
|
|
|
@@ -41,7 +45,8 @@ const FALSE_VALUES = new Set(["0", "false", "off", "no"]);
|
|
|
41
45
|
const DISABLED_LIST_KEYS = ["disabledModules", "disabledExtensions"];
|
|
42
46
|
const ENABLED_LIST_KEYS = ["enabledModules", "enabledExtensions"];
|
|
43
47
|
const MODULE_MAP_KEYS = ["modules", "extensions"];
|
|
44
|
-
const DEFAULT_DISABLED_MODULES = new Set<string>();
|
|
48
|
+
const DEFAULT_DISABLED_MODULES = new Set<string>(["credential-firewall"]);
|
|
49
|
+
const DEFAULT_TODO_THINKING_OVERRIDES = new Map<string, TodoThinkingLevel>([["zai/glm-5.3", "max"]]);
|
|
45
50
|
|
|
46
51
|
export function getPiToolsSuiteUserConfigPath(homeDir = homedir()): string {
|
|
47
52
|
return join(homeDir, ".config", "pi", "pi-tools-suite.jsonc");
|
|
@@ -79,6 +84,23 @@ function normalizeCodingDisciplineStrictness(raw: unknown): CodingDisciplineStri
|
|
|
79
84
|
return raw === "strict" ? "strict" : "lenient";
|
|
80
85
|
}
|
|
81
86
|
|
|
87
|
+
function isTodoThinkingLevel(raw: unknown): raw is TodoThinkingLevel {
|
|
88
|
+
return TODO_THINKING_OVERRIDE_LEVELS.includes(raw as TodoThinkingLevel);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function mergeTodoThinkingOverrides(config: MutableConfig, raw: unknown): void {
|
|
92
|
+
if (!isRecord(raw)) return;
|
|
93
|
+
for (const [rawPattern, value] of Object.entries(raw)) {
|
|
94
|
+
const pattern = rawPattern.trim().toLowerCase();
|
|
95
|
+
if (!pattern) continue;
|
|
96
|
+
if (value === null) {
|
|
97
|
+
config.todoThinkingOverrides.delete(pattern);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (isTodoThinkingLevel(value)) config.todoThinkingOverrides.set(pattern, value);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
82
104
|
function boolFromEnv(value: string | undefined): boolean | undefined {
|
|
83
105
|
if (value === undefined) return undefined;
|
|
84
106
|
const normalized = value.trim().toLowerCase();
|
|
@@ -137,6 +159,7 @@ function removeDisabled(config: MutableConfig, value: unknown, knownModules: Rea
|
|
|
137
159
|
function mergeConfigLayer(config: MutableConfig, raw: Record<string, unknown>, knownModules: ReadonlySet<string>): MutableConfig {
|
|
138
160
|
if (typeof raw.enabled === "boolean") config.enabled = raw.enabled;
|
|
139
161
|
if (typeof raw.todoThinking === "boolean") config.todoThinking = raw.todoThinking;
|
|
162
|
+
mergeTodoThinkingOverrides(config, raw.todoThinkingOverrides);
|
|
140
163
|
if (Object.prototype.hasOwnProperty.call(raw, "lookupModel")) config.lookupModel = normalizeLookupModel(raw.lookupModel);
|
|
141
164
|
if (Object.prototype.hasOwnProperty.call(raw, "codingDisciplineStrictness")) {
|
|
142
165
|
config.codingDisciplineStrictness = normalizeCodingDisciplineStrictness(raw.codingDisciplineStrictness);
|
|
@@ -197,6 +220,7 @@ export function loadPiToolsSuiteConfig(moduleNames: readonly string[], options:
|
|
|
197
220
|
enabled: true,
|
|
198
221
|
disabledModules: new Set([...DEFAULT_DISABLED_MODULES].filter((name) => knownModules.has(name))),
|
|
199
222
|
todoThinking: false,
|
|
223
|
+
todoThinkingOverrides: new Map(DEFAULT_TODO_THINKING_OVERRIDES),
|
|
200
224
|
lookupModel: undefined,
|
|
201
225
|
codingDisciplineStrictness: DEFAULT_CODING_DISCIPLINE_STRICTNESS,
|
|
202
226
|
};
|
|
@@ -217,6 +241,7 @@ export function loadPiToolsSuiteConfig(moduleNames: readonly string[], options:
|
|
|
217
241
|
enabled: config.enabled,
|
|
218
242
|
disabledModules: [...config.disabledModules].sort(),
|
|
219
243
|
todoThinking: config.todoThinking,
|
|
244
|
+
todoThinkingOverrides: Object.fromEntries(config.todoThinkingOverrides),
|
|
220
245
|
...(config.lookupModel ? { lookupModel: config.lookupModel } : {}),
|
|
221
246
|
codingDisciplineStrictness: config.codingDisciplineStrictness,
|
|
222
247
|
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, parse, resolve } from "node:path";
|
|
3
|
+
import { parse as parseJsonc } from "jsonc-parser";
|
|
4
|
+
|
|
5
|
+
import { getPiToolsSuiteUserConfigPath } from "../config.js";
|
|
6
|
+
|
|
7
|
+
export interface SecretFirewallConfig {
|
|
8
|
+
sessionHygiene: boolean;
|
|
9
|
+
notify: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const TRUE_VALUES = new Set(["1", "true", "on", "yes"]);
|
|
13
|
+
const FALSE_VALUES = new Set(["0", "false", "off", "no"]);
|
|
14
|
+
|
|
15
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
16
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function readJsonc(filePath: string): Record<string, unknown> {
|
|
20
|
+
if (!existsSync(filePath)) return {};
|
|
21
|
+
try {
|
|
22
|
+
const parsed = parseJsonc(readFileSync(filePath, "utf8"));
|
|
23
|
+
return isRecord(parsed) ? parsed : {};
|
|
24
|
+
} catch {
|
|
25
|
+
return {};
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function findProjectConfig(startDir: string): string | undefined {
|
|
30
|
+
let dir = resolve(startDir);
|
|
31
|
+
const root = parse(dir).root;
|
|
32
|
+
while (true) {
|
|
33
|
+
const candidate = join(dir, ".pi", "pi-tools-suite.jsonc");
|
|
34
|
+
if (existsSync(candidate)) return candidate;
|
|
35
|
+
if (dir === root) return undefined;
|
|
36
|
+
const parent = dirname(dir);
|
|
37
|
+
if (parent === dir) return undefined;
|
|
38
|
+
dir = parent;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function boolFromEnv(value: string | undefined): boolean | undefined {
|
|
43
|
+
if (value === undefined) return undefined;
|
|
44
|
+
const normalized = value.trim().toLowerCase();
|
|
45
|
+
if (TRUE_VALUES.has(normalized)) return true;
|
|
46
|
+
if (FALSE_VALUES.has(normalized)) return false;
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function loadSecretFirewallConfig(
|
|
51
|
+
cwd: string = process.cwd(),
|
|
52
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
53
|
+
homeDir: string = env.HOME ?? process.env.HOME ?? "",
|
|
54
|
+
): SecretFirewallConfig {
|
|
55
|
+
let sessionHygiene = true;
|
|
56
|
+
let notify = true;
|
|
57
|
+
|
|
58
|
+
const layers = [getPiToolsSuiteUserConfigPath(homeDir)];
|
|
59
|
+
if (env.PI_CONFIG_DIR) layers.push(join(env.PI_CONFIG_DIR, "pi-tools-suite.jsonc"));
|
|
60
|
+
const projectConfig = findProjectConfig(cwd);
|
|
61
|
+
if (projectConfig) layers.push(projectConfig);
|
|
62
|
+
|
|
63
|
+
for (const filePath of layers) {
|
|
64
|
+
const root = readJsonc(filePath);
|
|
65
|
+
const section = root.secretFirewall;
|
|
66
|
+
if (!isRecord(section)) continue;
|
|
67
|
+
if (typeof section.sessionHygiene === "boolean") sessionHygiene = section.sessionHygiene;
|
|
68
|
+
if (typeof section.notify === "boolean") notify = section.notify;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const envSessionHygiene = boolFromEnv(env.PI_SECRET_FIREWALL_SESSION_HYGIENE);
|
|
72
|
+
if (envSessionHygiene !== undefined) sessionHygiene = envSessionHygiene;
|
|
73
|
+
const envNotify = boolFromEnv(env.PI_SECRET_FIREWALL_NOTIFY);
|
|
74
|
+
if (envNotify !== undefined) notify = envNotify;
|
|
75
|
+
|
|
76
|
+
return { sessionHygiene, notify };
|
|
77
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { loadSecretFirewallConfig } from "./config.js";
|
|
2
|
+
import { SecretRedactor, type SecretKind, type SecretRedactionResult, type SecretRedactionSummary } from "./redact.js";
|
|
3
|
+
|
|
4
|
+
type ExtensionAPI = any;
|
|
5
|
+
type ExtensionContext = {
|
|
6
|
+
cwd?: string;
|
|
7
|
+
ui?: { notify?: (message: string, type?: "info" | "warning" | "error") => void };
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
function maybeNotify(ctx: ExtensionContext, result: SecretRedactionSummary, source: string, enabled: boolean): void {
|
|
11
|
+
if (!enabled || result.count === 0) return;
|
|
12
|
+
try {
|
|
13
|
+
ctx.ui?.notify?.(
|
|
14
|
+
`Secret firewall redacted ${result.count} secret${result.count === 1 ? "" : "s"} from ${source}: ${result.kinds.join(", ")}.`,
|
|
15
|
+
"warning",
|
|
16
|
+
);
|
|
17
|
+
} catch {
|
|
18
|
+
// Protection must not fail because a headless or stale UI cannot notify.
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export default function credentialFirewall(pi: ExtensionAPI): void {
|
|
23
|
+
const redactor = new SecretRedactor();
|
|
24
|
+
|
|
25
|
+
pi.on("before_provider_request", async (event: { payload?: unknown }, ctx: ExtensionContext) => {
|
|
26
|
+
const config = loadSecretFirewallConfig(ctx.cwd ?? process.cwd());
|
|
27
|
+
const result = redactor.redact(event.payload);
|
|
28
|
+
if (result.count === 0) return undefined;
|
|
29
|
+
maybeNotify(ctx, result, "provider payload", config.notify);
|
|
30
|
+
return result.value;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
pi.on("tool_result", async (event: { content?: unknown; details?: unknown }, ctx: ExtensionContext) => {
|
|
34
|
+
const config = loadSecretFirewallConfig(ctx.cwd ?? process.cwd());
|
|
35
|
+
if (!config.sessionHygiene) return undefined;
|
|
36
|
+
|
|
37
|
+
const content = redactor.redact(event.content);
|
|
38
|
+
const details = redactor.redact(event.details);
|
|
39
|
+
const count = content.count + details.count;
|
|
40
|
+
if (count === 0) return undefined;
|
|
41
|
+
const kinds = [...new Set([...content.kinds, ...details.kinds])] as SecretKind[];
|
|
42
|
+
maybeNotify(ctx, { count, kinds }, "tool result", config.notify);
|
|
43
|
+
return {
|
|
44
|
+
...(content.count > 0 ? { content: content.value } : {}),
|
|
45
|
+
...(details.count > 0 ? { details: details.value } : {}),
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
pi.on("message_end", async (event: { message?: unknown }, ctx: ExtensionContext) => {
|
|
50
|
+
const config = loadSecretFirewallConfig(ctx.cwd ?? process.cwd());
|
|
51
|
+
if (!config.sessionHygiene || !event.message) return undefined;
|
|
52
|
+
const result = redactor.redact(event.message);
|
|
53
|
+
if (result.count === 0) return undefined;
|
|
54
|
+
maybeNotify(ctx, result, "session message", config.notify);
|
|
55
|
+
return { message: result.value };
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export { loadSecretFirewallConfig } from "./config.js";
|
|
60
|
+
export { SecretRedactor } from "./redact.js";
|
|
61
|
+
export type { SecretFirewallConfig } from "./config.js";
|
|
62
|
+
export type { SecretKind, SecretRedactionResult, SecretRedactionSummary } from "./redact.js";
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export type SecretKind =
|
|
4
|
+
| "private_key"
|
|
5
|
+
| "github_token"
|
|
6
|
+
| "gitlab_token"
|
|
7
|
+
| "api_key"
|
|
8
|
+
| "google_api_key"
|
|
9
|
+
| "aws_access_key"
|
|
10
|
+
| "slack_token"
|
|
11
|
+
| "stripe_key"
|
|
12
|
+
| "npm_token"
|
|
13
|
+
| "pypi_token"
|
|
14
|
+
| "telegram_bot_token"
|
|
15
|
+
| "bearer_token"
|
|
16
|
+
| "basic_auth"
|
|
17
|
+
| "password"
|
|
18
|
+
| "credential";
|
|
19
|
+
|
|
20
|
+
export interface SecretRedactionSummary {
|
|
21
|
+
count: number;
|
|
22
|
+
kinds: SecretKind[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SecretRedactionResult<T = unknown> extends SecretRedactionSummary {
|
|
26
|
+
value: T;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type MatchPattern = { kind: SecretKind; pattern: RegExp };
|
|
30
|
+
|
|
31
|
+
const EXACT_SECRET_PATTERNS: MatchPattern[] = [
|
|
32
|
+
{ kind: "private_key", pattern: /-----BEGIN ([A-Z0-9 ]*PRIVATE KEY)-----[\s\S]*?-----END \1-----/g },
|
|
33
|
+
{ kind: "github_token", pattern: /\b(?:gh[pousr]_[A-Za-z0-9]{20,255}|github_pat_[A-Za-z0-9_]{20,255})\b/g },
|
|
34
|
+
{ kind: "gitlab_token", pattern: /\bglpat-[A-Za-z0-9_-]{20,255}\b/g },
|
|
35
|
+
{ kind: "api_key", pattern: /\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{24,255}\b/g },
|
|
36
|
+
{ kind: "google_api_key", pattern: /\bAIza[0-9A-Za-z_-]{35}\b/g },
|
|
37
|
+
{ kind: "aws_access_key", pattern: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g },
|
|
38
|
+
{ kind: "slack_token", pattern: /\bxox[baprs]-[A-Za-z0-9-]{20,255}\b/g },
|
|
39
|
+
{ kind: "stripe_key", pattern: /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,255}\b/g },
|
|
40
|
+
{ kind: "npm_token", pattern: /\bnpm_[A-Za-z0-9]{30,255}\b/g },
|
|
41
|
+
{ kind: "pypi_token", pattern: /\bpypi-[A-Za-z0-9_-]{40,255}\b/g },
|
|
42
|
+
{ kind: "telegram_bot_token", pattern: /\b\d{8,12}:[A-Za-z0-9_-]{30,}\b/g },
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
const SENSITIVE_ASSIGNMENT = /((?:["']?)(?:api[_-]?key|secret(?:[_-]?key)?|client[_-]?secret|access[_-]?token|refresh[_-]?token|auth[_-]?token|password|passwd|pwd|private[_-]?key|npm[_-]?token|github[_-]?token|gitlab[_-]?token|aws[_-]?access[_-]?key[_-]?id|aws[_-]?secret[_-]?access[_-]?key|aws[_-]?session[_-]?token)(?:["']?)\s*[:=]\s*)(["']?)([^\s"',}{;]{8,})(["']?)/gi;
|
|
46
|
+
const BEARER_AUTH = /(\bAuthorization\s*:\s*Bearer\s+)([A-Za-z0-9._~+\/=:-]{12,})/gi;
|
|
47
|
+
const BASIC_AUTH = /(\bAuthorization\s*:\s*Basic\s+)([A-Za-z0-9+/=]{8,})/gi;
|
|
48
|
+
const URL_PASSWORD = /(\b[a-z][a-z0-9+.-]*:\/\/[^:\s/@]+:)([^@\s/]{4,})(@)/gi;
|
|
49
|
+
|
|
50
|
+
const SAFE_EXACT_VALUES = new Set([
|
|
51
|
+
"[redacted]",
|
|
52
|
+
"redacted",
|
|
53
|
+
"placeholder",
|
|
54
|
+
"changeme",
|
|
55
|
+
"change-me",
|
|
56
|
+
"not-a-secret",
|
|
57
|
+
"not_secret",
|
|
58
|
+
"example",
|
|
59
|
+
"dummy",
|
|
60
|
+
"replace_me",
|
|
61
|
+
"replace-me",
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
function shouldSkipCandidate(value: string): boolean {
|
|
65
|
+
const normalized = value.trim().toLowerCase();
|
|
66
|
+
if (!normalized) return true;
|
|
67
|
+
if (normalized.includes("<secret:")) return true;
|
|
68
|
+
if (normalized.startsWith("$") || normalized.startsWith("${") || normalized.startsWith("{{")) return true;
|
|
69
|
+
if (normalized.startsWith("process.env") || normalized.startsWith("env.")) return true;
|
|
70
|
+
if (/^\*+$/.test(normalized) || /^x+$/.test(normalized)) return true;
|
|
71
|
+
if (/^your[_-][a-z0-9_-]+$/i.test(normalized)) return true;
|
|
72
|
+
return SAFE_EXACT_VALUES.has(normalized);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function kindForAssignmentPrefix(prefix: string): SecretKind {
|
|
76
|
+
const normalized = prefix.toLowerCase();
|
|
77
|
+
if (normalized.includes("password") || normalized.includes("passwd") || /\bpwd\b/.test(normalized)) return "password";
|
|
78
|
+
if (normalized.includes("api") && normalized.includes("key")) return "api_key";
|
|
79
|
+
if (normalized.includes("aws") && normalized.includes("access") && normalized.includes("key")) return "aws_access_key";
|
|
80
|
+
if (normalized.includes("private") && normalized.includes("key")) return "private_key";
|
|
81
|
+
return "credential";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function kindForSensitiveKey(key: string): SecretKind | undefined {
|
|
85
|
+
const normalized = key.toLowerCase().replace(/[^a-z0-9]+/g, "_");
|
|
86
|
+
if (normalized === "password" || normalized === "passwd" || normalized === "pwd") return "password";
|
|
87
|
+
if (normalized.includes("private_key")) return "private_key";
|
|
88
|
+
if (normalized.includes("api_key")) return "api_key";
|
|
89
|
+
if (normalized === "aws_access_key_id") return "aws_access_key";
|
|
90
|
+
if (
|
|
91
|
+
normalized.includes("secret") ||
|
|
92
|
+
normalized.includes("token") ||
|
|
93
|
+
normalized === "authorization" ||
|
|
94
|
+
normalized === "credential" ||
|
|
95
|
+
normalized === "credentials"
|
|
96
|
+
) return "credential";
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function uniqueKinds(kinds: SecretKind[]): SecretKind[] {
|
|
101
|
+
return [...new Set(kinds)];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
105
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
106
|
+
const prototype = Object.getPrototypeOf(value);
|
|
107
|
+
return prototype === Object.prototype || prototype === null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function shouldSkipOpaqueField(parent: Record<string, unknown>, key: string): boolean {
|
|
111
|
+
if (key === "encrypted_content" || key === "signature") return true;
|
|
112
|
+
return key === "data" && (parent.type === "image" || parent.type === "input_image" || parent.type === "image_url");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export class SecretRedactor {
|
|
116
|
+
// Keep only a one-way digest for stable placeholders; never retain plaintext
|
|
117
|
+
// secret material merely to correlate repeated redactions.
|
|
118
|
+
private readonly placeholders = new Map<string, string>();
|
|
119
|
+
private readonly counters = new Map<SecretKind, number>();
|
|
120
|
+
|
|
121
|
+
private placeholder(secret: string, kind: SecretKind): string {
|
|
122
|
+
const fingerprint = createHash("sha256").update(secret).digest("hex");
|
|
123
|
+
const existing = this.placeholders.get(fingerprint);
|
|
124
|
+
if (existing) return existing;
|
|
125
|
+
const next = (this.counters.get(kind) ?? 0) + 1;
|
|
126
|
+
this.counters.set(kind, next);
|
|
127
|
+
const placeholder = `<SECRET:${kind}:${next}>`;
|
|
128
|
+
this.placeholders.set(fingerprint, placeholder);
|
|
129
|
+
return placeholder;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
redactString(input: string): SecretRedactionResult<string> {
|
|
133
|
+
let value = input;
|
|
134
|
+
let count = 0;
|
|
135
|
+
const kinds: SecretKind[] = [];
|
|
136
|
+
|
|
137
|
+
const replaceSecret = (secret: string, kind: SecretKind): string => {
|
|
138
|
+
if (shouldSkipCandidate(secret)) return secret;
|
|
139
|
+
count++;
|
|
140
|
+
kinds.push(kind);
|
|
141
|
+
return this.placeholder(secret, kind);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
SENSITIVE_ASSIGNMENT.lastIndex = 0;
|
|
145
|
+
value = value.replace(SENSITIVE_ASSIGNMENT, (match, prefix: string, openingQuote: string, secret: string, closingQuote: string) => {
|
|
146
|
+
if (openingQuote && closingQuote && openingQuote !== closingQuote) return match;
|
|
147
|
+
const replacement = replaceSecret(secret, kindForAssignmentPrefix(prefix));
|
|
148
|
+
return `${prefix}${openingQuote}${replacement}${closingQuote}`;
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
BEARER_AUTH.lastIndex = 0;
|
|
152
|
+
value = value.replace(BEARER_AUTH, (_match, prefix: string, secret: string) => `${prefix}${replaceSecret(secret, "bearer_token")}`);
|
|
153
|
+
|
|
154
|
+
BASIC_AUTH.lastIndex = 0;
|
|
155
|
+
value = value.replace(BASIC_AUTH, (_match, prefix: string, secret: string) => `${prefix}${replaceSecret(secret, "basic_auth")}`);
|
|
156
|
+
|
|
157
|
+
URL_PASSWORD.lastIndex = 0;
|
|
158
|
+
value = value.replace(URL_PASSWORD, (_match, prefix: string, secret: string, suffix: string) => `${prefix}${replaceSecret(secret, "password")}${suffix}`);
|
|
159
|
+
|
|
160
|
+
// Exact token formats run last so placeholders introduced by contextual
|
|
161
|
+
// detectors are never reinterpreted as new secret assignments.
|
|
162
|
+
for (const { kind, pattern } of EXACT_SECRET_PATTERNS) {
|
|
163
|
+
pattern.lastIndex = 0;
|
|
164
|
+
value = value.replace(pattern, (secret) => replaceSecret(secret, kind));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return { value, count, kinds: uniqueKinds(kinds) };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
redact<T>(input: T): SecretRedactionResult<T> {
|
|
171
|
+
const result = this.redactValue(input);
|
|
172
|
+
return { value: result.value as T, count: result.count, kinds: uniqueKinds(result.kinds) };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private redactValue(input: unknown): SecretRedactionResult {
|
|
176
|
+
if (typeof input === "string") return this.redactString(input);
|
|
177
|
+
if (Array.isArray(input)) {
|
|
178
|
+
let changed = false;
|
|
179
|
+
let count = 0;
|
|
180
|
+
const kinds: SecretKind[] = [];
|
|
181
|
+
const next = input.map((item) => {
|
|
182
|
+
const result = this.redactValue(item);
|
|
183
|
+
if (result.value !== item) changed = true;
|
|
184
|
+
count += result.count;
|
|
185
|
+
kinds.push(...result.kinds);
|
|
186
|
+
return result.value;
|
|
187
|
+
});
|
|
188
|
+
return { value: changed ? next : input, count, kinds };
|
|
189
|
+
}
|
|
190
|
+
if (!isPlainRecord(input)) return { value: input, count: 0, kinds: [] };
|
|
191
|
+
|
|
192
|
+
let changed = false;
|
|
193
|
+
let count = 0;
|
|
194
|
+
const kinds: SecretKind[] = [];
|
|
195
|
+
const next: Record<string, unknown> = {};
|
|
196
|
+
for (const [key, item] of Object.entries(input)) {
|
|
197
|
+
if (typeof item === "string" && shouldSkipOpaqueField(input, key)) {
|
|
198
|
+
next[key] = item;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (typeof item === "string") {
|
|
202
|
+
const keyKind = kindForSensitiveKey(key);
|
|
203
|
+
if (keyKind && !shouldSkipCandidate(item)) {
|
|
204
|
+
const replacement = this.placeholder(item, keyKind);
|
|
205
|
+
next[key] = replacement;
|
|
206
|
+
if (replacement !== item) {
|
|
207
|
+
changed = true;
|
|
208
|
+
count++;
|
|
209
|
+
kinds.push(keyKind);
|
|
210
|
+
}
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const result = this.redactValue(item);
|
|
215
|
+
next[key] = result.value;
|
|
216
|
+
if (result.value !== item) changed = true;
|
|
217
|
+
count += result.count;
|
|
218
|
+
kinds.push(...result.kinds);
|
|
219
|
+
}
|
|
220
|
+
return { value: changed ? next : input, count, kinds };
|
|
221
|
+
}
|
|
222
|
+
}
|
|
@@ -5,9 +5,22 @@ export const DEFAULT_PI_TOOLS_SUITE_CONFIG_JSONC = String.raw`{
|
|
|
5
5
|
// "ast-grep",
|
|
6
6
|
// "dcp"
|
|
7
7
|
],
|
|
8
|
+
// Secret firewall is deliberately opt-in for now. Flip this to true to enable
|
|
9
|
+
// high-confidence outbound redaction plus session-history hygiene.
|
|
10
|
+
"modules": { "credential-firewall": false },
|
|
11
|
+
"secretFirewall": {
|
|
12
|
+
"sessionHygiene": true,
|
|
13
|
+
"notify": true
|
|
14
|
+
},
|
|
8
15
|
// When true, todo items may carry a per-task thinking level and the todo
|
|
9
16
|
// module will switch/restore Pi's thinking level as in-progress tasks change.
|
|
10
17
|
"todoThinking": true,
|
|
18
|
+
// Force every todo mutation made under matching models to the configured
|
|
19
|
+
// thinking level. Supports provider/model or bare-model keys with * / ? globs.
|
|
20
|
+
// Set an inherited key to null in a later config layer to remove it.
|
|
21
|
+
"todoThinkingOverrides": {
|
|
22
|
+
"zai/glm-5.3": "max"
|
|
23
|
+
},
|
|
11
24
|
// Vision-capable model used by the coding-discipline lookup tool for blind-model
|
|
12
25
|
// screenshot/image questions. Remove or set to null to disable lookup.
|
|
13
26
|
"lookupModel": "openai-codex/gpt-5.4-mini",
|
|
@@ -26,6 +26,8 @@ export const MODULES: Array<{ name: string; load: () => Promise<ExtensionModule>
|
|
|
26
26
|
{ name: "dcp", load: () => import("./dcp/index") },
|
|
27
27
|
{ name: "prompt-commands", load: () => import("./prompt-commands/index") },
|
|
28
28
|
{ name: "skill-installer", load: () => import("./skill-installer/index") },
|
|
29
|
+
// Secret firewall is intentionally opt-in. Keep it after payload-shaping modules.
|
|
30
|
+
{ name: "credential-firewall", load: () => import("./credential-firewall/index") },
|
|
29
31
|
// Keep this last: its before_provider_request handler is the final payload
|
|
30
32
|
// sanitizer after DCP and any other provider-payload modifiers.
|
|
31
33
|
{ name: "codex-reasoning-fix", load: () => import("./codex-reasoning-fix/index") },
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { loadPiToolsSuiteConfig } from "../config.js";
|
|
2
|
+
import { loadPiToolsSuiteConfig, type TodoThinkingLevel as ConfigTodoThinkingLevel } from "../config.js";
|
|
3
3
|
import { isAgentBusyRaceError } from "../context-usage.js";
|
|
4
4
|
import { autoClearCompletedTodos } from "./state/auto-clear.js";
|
|
5
5
|
import { loadPersistedPlan, syncPersistedPlan } from "./state/persistence.js";
|
|
@@ -31,6 +31,7 @@ function isStaleExtensionContextError(error: unknown): boolean {
|
|
|
31
31
|
|
|
32
32
|
type ModelLike = {
|
|
33
33
|
provider?: string;
|
|
34
|
+
providerId?: string;
|
|
34
35
|
id?: string;
|
|
35
36
|
modelId?: string;
|
|
36
37
|
reasoning?: boolean;
|
|
@@ -38,6 +39,51 @@ type ModelLike = {
|
|
|
38
39
|
compat?: { thinkingFormat?: unknown };
|
|
39
40
|
};
|
|
40
41
|
|
|
42
|
+
function escapeRegExp(text: string): string {
|
|
43
|
+
return text.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function modelPatternMatches(pattern: string, candidate: string): boolean {
|
|
47
|
+
let source = "^";
|
|
48
|
+
for (const char of pattern) {
|
|
49
|
+
if (char === "*") source += ".*";
|
|
50
|
+
else if (char === "?") source += ".";
|
|
51
|
+
else source += escapeRegExp(char);
|
|
52
|
+
}
|
|
53
|
+
return new RegExp(`${source}$`, "i").test(candidate);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function modelKeys(model: unknown): { bare?: string; full?: string } {
|
|
57
|
+
const candidate = model as ModelLike | undefined;
|
|
58
|
+
const provider = candidate?.provider ?? candidate?.providerId;
|
|
59
|
+
const rawId = candidate?.modelId ?? candidate?.id;
|
|
60
|
+
if (!rawId) return {};
|
|
61
|
+
if (rawId.includes("/")) {
|
|
62
|
+
const slash = rawId.indexOf("/");
|
|
63
|
+
return { bare: rawId.slice(slash + 1), full: rawId };
|
|
64
|
+
}
|
|
65
|
+
return { bare: rawId, ...(provider ? { full: `${provider}/${rawId}` } : {}) };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function resolveTodoThinkingOverride(
|
|
69
|
+
model: unknown,
|
|
70
|
+
overrides: Record<string, ConfigTodoThinkingLevel>,
|
|
71
|
+
): TodoThinkingLevel | undefined {
|
|
72
|
+
const keys = modelKeys(model);
|
|
73
|
+
let best: { level: TodoThinkingLevel; score: number } | undefined;
|
|
74
|
+
for (const [rawPattern, level] of Object.entries(overrides)) {
|
|
75
|
+
const pattern = rawPattern.trim();
|
|
76
|
+
const isFull = pattern.includes("/");
|
|
77
|
+
const candidate = isFull ? keys.full : keys.bare;
|
|
78
|
+
if (!candidate || !modelPatternMatches(pattern, candidate)) continue;
|
|
79
|
+
const exact = !pattern.includes("*") && !pattern.includes("?");
|
|
80
|
+
const literalLength = pattern.replace(/[?*]/g, "").length;
|
|
81
|
+
const score = (isFull ? 30_000 : 10_000) + (exact ? 10_000 : 0) + literalLength;
|
|
82
|
+
if (!best || score >= best.score) best = { level, score };
|
|
83
|
+
}
|
|
84
|
+
return best?.level;
|
|
85
|
+
}
|
|
86
|
+
|
|
41
87
|
function isTodoThinkingLevel(value: unknown): value is TodoThinkingLevel {
|
|
42
88
|
return TODO_THINKING_LEVEL_VALUES.includes(value as TodoThinkingLevel);
|
|
43
89
|
}
|
|
@@ -167,7 +213,9 @@ function emitPersistedPlanPrompt(pi: ExtensionAPI, ctx: ExtensionContext, prompt
|
|
|
167
213
|
|
|
168
214
|
export default function (pi: ExtensionAPI) {
|
|
169
215
|
let currentModel: unknown;
|
|
170
|
-
const
|
|
216
|
+
const todoConfig = loadPiToolsSuiteConfig(["todo"]);
|
|
217
|
+
const todoThinkingEnabled = todoConfig.todoThinking;
|
|
218
|
+
const todoThinkingOverrides = todoConfig.todoThinkingOverrides;
|
|
171
219
|
const rememberedThinkingByTaskId = new Map<number, TodoThinkingLevel>();
|
|
172
220
|
let lastNudgedSignature: string | undefined;
|
|
173
221
|
let nudgeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -237,7 +285,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
237
285
|
|
|
238
286
|
function prepareTodoThinkingMutation(state: ReturnType<typeof getState>, params: TaskMutationParams): TaskMutationParams {
|
|
239
287
|
let nextParams = params;
|
|
240
|
-
|
|
288
|
+
const configuredOverride = resolveTodoThinkingOverride(currentModel, todoThinkingOverrides);
|
|
289
|
+
if (configuredOverride !== undefined) {
|
|
290
|
+
const forced = normalizeTodoThinkingLevelForModel(currentModel, configuredOverride);
|
|
291
|
+
nextParams = { ...nextParams, thinking: forced };
|
|
292
|
+
} else if (params.thinking !== undefined) {
|
|
241
293
|
const normalized = normalizeTodoThinkingLevelForModel(currentModel, params.thinking);
|
|
242
294
|
if (normalized !== params.thinking) nextParams = { ...nextParams, thinking: normalized };
|
|
243
295
|
}
|
package/package.json
CHANGED
|
@@ -16,10 +16,67 @@
|
|
|
16
16
|
},
|
|
17
17
|
"description": "List of disabled module names (e.g. ['lsp', 'prompt-commands'])."
|
|
18
18
|
},
|
|
19
|
+
"enabledModules": {
|
|
20
|
+
"type": "array",
|
|
21
|
+
"items": {
|
|
22
|
+
"type": "string"
|
|
23
|
+
},
|
|
24
|
+
"description": "List of module names to explicitly enable, including modules that are disabled by default."
|
|
25
|
+
},
|
|
26
|
+
"modules": {
|
|
27
|
+
"type": "object",
|
|
28
|
+
"patternProperties": {
|
|
29
|
+
"^.*$": {
|
|
30
|
+
"type": "boolean"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"description": "Per-module enable/disable map. credential-firewall is disabled by default and can be enabled here."
|
|
34
|
+
},
|
|
19
35
|
"todoThinking": {
|
|
20
36
|
"type": "boolean",
|
|
21
37
|
"description": "Enable per-todo thinking levels and automatic thinking switch/restore when tasks become in-progress/completed."
|
|
22
38
|
},
|
|
39
|
+
"todoThinkingOverrides": {
|
|
40
|
+
"type": "object",
|
|
41
|
+
"patternProperties": {
|
|
42
|
+
"^.*$": {
|
|
43
|
+
"anyOf": [
|
|
44
|
+
{
|
|
45
|
+
"type": "string",
|
|
46
|
+
"const": "off"
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"type": "string",
|
|
50
|
+
"const": "minimal"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"type": "string",
|
|
54
|
+
"const": "low"
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"type": "string",
|
|
58
|
+
"const": "medium"
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"type": "string",
|
|
62
|
+
"const": "high"
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"type": "string",
|
|
66
|
+
"const": "xhigh"
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"type": "string",
|
|
70
|
+
"const": "max"
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"type": "null"
|
|
74
|
+
}
|
|
75
|
+
]
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
"description": "Force per-todo thinking for matching provider/model or bare-model keys. Keys support * and ? wildcards; null removes an inherited override."
|
|
79
|
+
},
|
|
23
80
|
"lookupModel": {
|
|
24
81
|
"anyOf": [
|
|
25
82
|
{
|
|
@@ -812,6 +869,20 @@
|
|
|
812
869
|
},
|
|
813
870
|
"description": "User-defined slash commands."
|
|
814
871
|
},
|
|
872
|
+
"secretFirewall": {
|
|
873
|
+
"type": "object",
|
|
874
|
+
"properties": {
|
|
875
|
+
"sessionHygiene": {
|
|
876
|
+
"type": "boolean",
|
|
877
|
+
"description": "Redact detected secret material from tool results and completed messages before it remains in session history."
|
|
878
|
+
},
|
|
879
|
+
"notify": {
|
|
880
|
+
"type": "boolean",
|
|
881
|
+
"description": "Show a warning when one or more secrets are redacted. Secret values are never included in notifications."
|
|
882
|
+
}
|
|
883
|
+
},
|
|
884
|
+
"description": "Settings for the opt-in credential-firewall module."
|
|
885
|
+
},
|
|
815
886
|
"lsp": {
|
|
816
887
|
"type": "object",
|
|
817
888
|
"properties": {
|