pi-ui-extend 1.0.12 → 1.0.14

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 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`: 17 integrated modules for indexed repository discovery, AST edits, LSP diagnostics, parallel agents, durable todos, context compression, web access, provider accounts, and more.
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.
@@ -46,6 +46,13 @@ type RuntimeSessionManagerModelState = Pick<SessionManager, "getEntries" | "getB
46
46
  export declare function resolvePixRuntimeModelRef(options: Pick<AppOptions, "modelRef">, sessionManager: RuntimeSessionManagerModelState, config?: PixConfig): string | undefined;
47
47
  export declare function resolvePixRuntimeInitialThinkingLevel(options: Pick<AppOptions, "modelRef">, sessionManager: RuntimeSessionManagerModelState, config: PixConfig): ThinkingLevel | undefined;
48
48
  export declare function resolveSessionModelRefFromTail(entries: readonly SessionEntry[]): string | undefined;
49
- export declare function refreshPixModelRuntimeForStartup(modelRuntime: Pick<AgentSessionServices["modelRuntime"], "refresh">): Promise<void>;
49
+ /**
50
+ * pi-ai 0.84.2 predates GLM-5.3's final thinking contract. The released model
51
+ * is always-thinking and accepts exactly low/high/max via reasoning_effort.
52
+ * Patch the mutable catalog model before AgentSession clamps the selected level.
53
+ */
54
+ export declare function patchGlm53ThinkingMetadata(model: unknown): boolean;
55
+ export declare function patchPixModelRuntimeCompatibility(modelRuntime: Pick<AgentSessionServices["modelRuntime"], "getModels">): number;
56
+ export declare function refreshPixModelRuntimeForStartup(modelRuntime: Pick<AgentSessionServices["modelRuntime"], "refresh" | "getModels">): Promise<void>;
50
57
  export declare function createPixRuntime(options: AppOptions, runtimeOptions?: CreatePixRuntimeOptions): Promise<AgentSessionRuntime>;
51
58
  export {};
@@ -235,10 +235,49 @@ export function resolveSessionModelRefFromTail(entries) {
235
235
  return undefined;
236
236
  return thinkingLevel ? `${modelRef}:${thinkingLevel}` : modelRef;
237
237
  }
238
+ const GLM_53_THINKING_LEVEL_MAP = {
239
+ off: null,
240
+ minimal: null,
241
+ low: "low",
242
+ medium: null,
243
+ high: "high",
244
+ xhigh: null,
245
+ max: "max",
246
+ };
247
+ /**
248
+ * pi-ai 0.84.2 predates GLM-5.3's final thinking contract. The released model
249
+ * is always-thinking and accepts exactly low/high/max via reasoning_effort.
250
+ * Patch the mutable catalog model before AgentSession clamps the selected level.
251
+ */
252
+ export function patchGlm53ThinkingMetadata(model) {
253
+ if (!model || typeof model !== "object" || Array.isArray(model))
254
+ return false;
255
+ const candidate = model;
256
+ if (candidate.id !== "glm-5.3")
257
+ return false;
258
+ if (!candidate.compat || typeof candidate.compat !== "object" || Array.isArray(candidate.compat))
259
+ return false;
260
+ const compat = candidate.compat;
261
+ if (compat.thinkingFormat !== "zai")
262
+ return false;
263
+ candidate.reasoning = true;
264
+ candidate.thinkingLevelMap = { ...GLM_53_THINKING_LEVEL_MAP };
265
+ candidate.compat = { ...compat, supportsReasoningEffort: true };
266
+ return true;
267
+ }
268
+ export function patchPixModelRuntimeCompatibility(modelRuntime) {
269
+ let patched = 0;
270
+ for (const model of modelRuntime.getModels()) {
271
+ if (patchGlm53ThinkingMetadata(model))
272
+ patched += 1;
273
+ }
274
+ return patched;
275
+ }
238
276
  export async function refreshPixModelRuntimeForStartup(modelRuntime) {
239
277
  // Startup only needs the locally configured model catalog. Remote catalog
240
278
  // refreshes belong to explicit model-management flows and must not block boot.
241
279
  await modelRuntime.refresh({ allowNetwork: false });
280
+ patchPixModelRuntimeCompatibility(modelRuntime);
242
281
  }
243
282
  export async function createPixRuntime(options, runtimeOptions = {}) {
244
283
  const agentDir = getAgentDir();
@@ -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
- this.addEntry({ id: createId("error"), kind: "error", text: assistantEvent.error.errorMessage ?? assistantEvent.reason });
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,6 +9,8 @@ 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>;
13
15
  lookupModel: Type.TOptional<Type.TUnion<[Type.TString, Type.TNull]>>;
14
16
  terminalBell: Type.TOptional<Type.TObject<{
@@ -163,6 +165,10 @@ export declare const PiToolsSuiteConfigSchema: Type.TObject<{
163
165
  prompt: Type.TString;
164
166
  }>>>;
165
167
  }>>;
168
+ secretFirewall: Type.TOptional<Type.TObject<{
169
+ sessionHygiene: Type.TOptional<Type.TBoolean>;
170
+ notify: Type.TOptional<Type.TBoolean>;
171
+ }>>;
166
172
  lsp: Type.TOptional<Type.TObject<{
167
173
  servers: Type.TOptional<Type.TArray<Type.TObject<{
168
174
  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,6 +218,8 @@ 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." })),
218
224
  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
225
  terminalBell: Type.Optional(TerminalBellConfig),
@@ -221,6 +227,7 @@ export const PiToolsSuiteConfigSchema = Type.Object({
221
227
  asyncSubagents: Type.Optional(AsyncSubagentsConfig),
222
228
  toolRenderer: Type.Optional(ToolRendererConfig),
223
229
  promptCommands: Type.Optional(PromptCommandsConfig),
230
+ secretFirewall: Type.Optional(SecretFirewallConfig),
224
231
  lsp: Type.Optional(LspConfig),
225
232
  }, {
226
233
  $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 skill-installer. 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
+ 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
@@ -49,6 +49,16 @@ const SILENCE_REMINDER_MIN_MESSAGE_GAP = 20;
49
49
  // truncation), the chatter baseline is reset so it isn't measured against a stale peak.
50
50
  const SILENCE_REMINDER_COMPACTION_MARGIN = 8;
51
51
  const LOOKUP_TOOL_NAME = "lookup";
52
+ const GLM_53_THINKING_LEVEL_MAP = {
53
+ off: null,
54
+ minimal: null,
55
+ low: "low",
56
+ medium: null,
57
+ high: "high",
58
+ xhigh: null,
59
+ max: "max",
60
+ } as const;
61
+ const GLM_53_THINKING_LEVELS = ["low", "high", "max"] as const;
52
62
 
53
63
  const LOOKUP_TOOL_PARAMS = Type.Object(
54
64
  {
@@ -203,13 +213,17 @@ export default function codingDiscipline(pi: ExtensionAPI) {
203
213
  maybeRegisterLookupTool(process.cwd());
204
214
 
205
215
  pi.on("session_start", async (_event: unknown, ctx: unknown) => {
216
+ patchGlm53ThinkingModels(ctx);
206
217
  selectedModelRef = modelRefFromContext(ctx);
218
+ normalizeCurrentGlm53ThinkingLevel(pi, selectedModelRef);
207
219
  maybeRegisterLookupTool(contextCwd(ctx));
208
220
  syncLookupToolAvailability(selectedModelRef, contextCwd(ctx));
209
221
  });
210
222
 
211
223
  pi.on("model_select", async (event: { model?: unknown }, ctx: unknown) => {
224
+ patchGlm53ThinkingModels(ctx, event.model);
212
225
  selectedModelRef = modelRefFromModel(event.model) ?? modelRefFromContext(ctx);
226
+ normalizeCurrentGlm53ThinkingLevel(pi, selectedModelRef);
213
227
  maybeRegisterLookupTool(contextCwd(ctx));
214
228
  syncLookupToolAvailability(selectedModelRef, contextCwd(ctx));
215
229
  });
@@ -222,10 +236,11 @@ export default function codingDiscipline(pi: ExtensionAPI) {
222
236
  lookupEnabled: Boolean(lookupModelFromConfig(cwd)),
223
237
  strictness: codingDisciplineStrictnessFromConfig(cwd),
224
238
  });
239
+ const corrected = applyGlm53ThinkingToPayload(injected, modelRef, ctx, pi);
225
240
  if (process.env.PI_DEBUG_PROMPT === "1") {
226
- logFinalPrompt(injected, modelRef, contextCwd(ctx) ?? process.cwd());
241
+ logFinalPrompt(corrected, modelRef, contextCwd(ctx) ?? process.cwd());
227
242
  }
228
- return injected;
243
+ return corrected;
229
244
  });
230
245
 
231
246
  pi.on("before_agent_start", async (event: { systemPromptOptions?: unknown; systemPrompt?: string }, ctx: unknown) => {
@@ -350,6 +365,83 @@ export function isGlmModel(modelRef: string | undefined): boolean {
350
365
  return /(?:^|[/:_.-])glm(?:$|[/:_.-]|\d)/i.test(modelRef);
351
366
  }
352
367
 
368
+ function isGlm53ModelRef(modelRef: string | undefined): boolean {
369
+ if (!modelRef) return false;
370
+ return /(?:^|\/)glm-5\.3(?:$|:)/i.test(modelRef) || /^glm-5\.3(?:$|:)/i.test(modelRef);
371
+ }
372
+
373
+ function patchGlm53ThinkingModel(model: unknown): boolean {
374
+ if (!isRecord(model)) return false;
375
+ const id = typeof model.id === "string"
376
+ ? model.id
377
+ : typeof model.modelId === "string"
378
+ ? model.modelId
379
+ : undefined;
380
+ if (id !== "glm-5.3") return false;
381
+ const provider = typeof model.provider === "string" ? model.provider : undefined;
382
+ const compat = isRecord(model.compat) ? model.compat : {};
383
+ if (provider !== "zai" && provider !== "zai-coding-cn" && compat.thinkingFormat !== "zai") return false;
384
+
385
+ model.reasoning = true;
386
+ model.thinkingLevelMap = { ...GLM_53_THINKING_LEVEL_MAP };
387
+ model.compat = { ...compat, supportsReasoningEffort: true };
388
+ return true;
389
+ }
390
+
391
+ function patchGlm53ThinkingModels(ctx: unknown, selectedModel?: unknown): void {
392
+ patchGlm53ThinkingModel(selectedModel);
393
+ if (!isRecord(ctx)) return;
394
+ patchGlm53ThinkingModel(ctx.model);
395
+
396
+ if (Array.isArray(ctx.scopedModels)) {
397
+ for (const entry of ctx.scopedModels) {
398
+ if (isRecord(entry)) patchGlm53ThinkingModel(entry.model);
399
+ }
400
+ }
401
+
402
+ const registry = ctx.modelRegistry;
403
+ if (!isRecord(registry) || typeof registry.getAll !== "function") return;
404
+ try {
405
+ const models = registry.getAll();
406
+ if (Array.isArray(models)) {
407
+ for (const model of models) patchGlm53ThinkingModel(model);
408
+ }
409
+ } catch {
410
+ // Compatibility patching must never break session startup.
411
+ }
412
+ }
413
+
414
+ function normalizeGlm53ThinkingLevel(level: unknown): (typeof GLM_53_THINKING_LEVELS)[number] {
415
+ if (level === "low" || level === "high" || level === "max") return level;
416
+ if (level === "off" || level === "minimal") return "low";
417
+ if (level === "medium") return "high";
418
+ return "max";
419
+ }
420
+
421
+ function normalizeCurrentGlm53ThinkingLevel(pi: ExtensionAPI, modelRef: string | undefined): void {
422
+ if (!isGlm53ModelRef(modelRef)) return;
423
+ const getter = (pi as { getThinkingLevel?: () => unknown }).getThinkingLevel;
424
+ const setter = (pi as { setThinkingLevel?: (level: string) => void }).setThinkingLevel;
425
+ if (!getter || !setter) return;
426
+ const current = getter.call(pi);
427
+ const normalized = normalizeGlm53ThinkingLevel(current);
428
+ if (current !== normalized) setter.call(pi, normalized);
429
+ }
430
+
431
+ function applyGlm53ThinkingToPayload(payload: unknown, modelRef: string | undefined, ctx: unknown, pi: ExtensionAPI): unknown {
432
+ if (!isGlm53ModelRef(modelRef) || !isRecord(payload)) return payload;
433
+ const getter = (pi as { getThinkingLevel?: () => unknown }).getThinkingLevel;
434
+ const runtimeLevel = getter ? getter.call(pi) : undefined;
435
+ const contextLevel = isRecord(ctx) ? ctx.thinkingLevel : undefined;
436
+ const effort = normalizeGlm53ThinkingLevel(runtimeLevel ?? contextLevel);
437
+ const existingThinking = isRecord(payload.thinking) ? payload.thinking : {};
438
+ return {
439
+ ...payload,
440
+ thinking: { ...existingThinking, type: "enabled", clear_thinking: false },
441
+ reasoning_effort: effort,
442
+ };
443
+ }
444
+
353
445
  export function injectCodingDisciplineIntoPayload(payload: unknown, options: DisciplinePromptOptions = {}): unknown {
354
446
  if (!isRecord(payload)) return payload;
355
447
 
@@ -41,7 +41,7 @@ const FALSE_VALUES = new Set(["0", "false", "off", "no"]);
41
41
  const DISABLED_LIST_KEYS = ["disabledModules", "disabledExtensions"];
42
42
  const ENABLED_LIST_KEYS = ["enabledModules", "enabledExtensions"];
43
43
  const MODULE_MAP_KEYS = ["modules", "extensions"];
44
- const DEFAULT_DISABLED_MODULES = new Set<string>();
44
+ const DEFAULT_DISABLED_MODULES = new Set<string>(["credential-firewall"]);
45
45
 
46
46
  export function getPiToolsSuiteUserConfigPath(homeDir = homedir()): string {
47
47
  return join(homeDir, ".config", "pi", "pi-tools-suite.jsonc");
@@ -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,6 +5,13 @@ 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,
@@ -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") },
@@ -8,7 +8,13 @@ import { ACTIVE_STATUSES, isTaskBlocked, selectVisibleTasks } from "./state/sele
8
8
  import { applyTaskMutation } from "./state/state-reducer.js";
9
9
  import { getState, replaceState } from "./state/store.js";
10
10
  import { activateTodoStateScope, DEFAULT_PROMPT_GUIDELINES, DEFAULT_PROMPT_SNIPPET, publishTodoState, registerTodosCommand, registerTodoTool } from "./todo.js";
11
- import type { Task, TaskMutationParams } from "./tool/types.js";
11
+ import {
12
+ TODO_THINKING_LEVEL_VALUES,
13
+ todoParamsSchemaForThinkingLevels,
14
+ type Task,
15
+ type TaskMutationParams,
16
+ type TodoThinkingLevel,
17
+ } from "./tool/types.js";
12
18
 
13
19
  type AgentMessageLike = { role?: unknown; stopReason?: unknown; content?: unknown };
14
20
 
@@ -17,39 +23,73 @@ const TODO_NUDGE_INITIAL_DELAY_MS = 0;
17
23
  const TODO_NUDGE_IDLE_RETRY_DELAY_MS = 100;
18
24
  const TODO_NUDGE_MAX_IDLE_ATTEMPTS = 40;
19
25
  const ASK_USER_TOOL_NAMES = new Set(["ask_user", "ask_user_question", "question"]);
20
- const TODO_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
21
26
  const TODO_THINKING_RESTORE_METADATA_KEY = "__piTodoRestoreThinking";
22
27
 
23
28
  function isStaleExtensionContextError(error: unknown): boolean {
24
29
  return error instanceof Error && /ctx is stale|stale ctx|stale after session replacement|stale after.*reload/i.test(error.message);
25
30
  }
26
31
 
27
- type TodoThinkingLevel = (typeof TODO_THINKING_LEVELS)[number];
28
- type ModelLike = { reasoning?: boolean; thinkingLevelMap?: Partial<Record<TodoThinkingLevel, unknown | null>> };
32
+ type ModelLike = {
33
+ provider?: string;
34
+ id?: string;
35
+ modelId?: string;
36
+ reasoning?: boolean;
37
+ thinkingLevelMap?: Partial<Record<TodoThinkingLevel, unknown | null>>;
38
+ compat?: { thinkingFormat?: unknown };
39
+ };
29
40
 
30
41
  function isTodoThinkingLevel(value: unknown): value is TodoThinkingLevel {
31
- return TODO_THINKING_LEVELS.includes(value as TodoThinkingLevel);
42
+ return TODO_THINKING_LEVEL_VALUES.includes(value as TodoThinkingLevel);
43
+ }
44
+
45
+ function isGlm53TodoModel(model: ModelLike | undefined): boolean {
46
+ if (!model) return false;
47
+ const id = model.modelId ?? model.id;
48
+ if (id !== "glm-5.3") return false;
49
+ return model.provider === "zai" || model.provider === "zai-coding-cn" || model.compat?.thinkingFormat === "zai";
32
50
  }
33
51
 
34
52
  function getAvailableTodoThinkingLevels(model: unknown): TodoThinkingLevel[] {
35
53
  const m = model as ModelLike | undefined;
36
54
  if (!m?.reasoning) return ["off"];
55
+ if (isGlm53TodoModel(m)) return ["low", "high", "max"];
37
56
  const map = m.thinkingLevelMap;
38
- return TODO_THINKING_LEVELS.filter((level) => level === "off" || map?.[level] !== null);
57
+ return TODO_THINKING_LEVEL_VALUES.filter((level) => {
58
+ const mapped = map?.[level];
59
+ if (mapped === null) return false;
60
+ if (level === "xhigh" || level === "max") return mapped !== undefined;
61
+ return true;
62
+ });
39
63
  }
40
64
 
41
65
  function buildThinkingPromptParts(model: unknown): { promptSnippet?: string; promptGuidelines?: string[] } {
42
66
  const levels = getAvailableTodoThinkingLevels(model);
43
67
  if (levels.length <= 1) return {};
68
+ const lowEffortWording = levels.includes("off") ? "lower/off" : "lower";
44
69
  return {
45
70
  promptSnippet: `${DEFAULT_PROMPT_SNIPPET} Set per-item thinking: ${levels.join("|")}.`.trim(),
46
71
  promptGuidelines: [
47
72
  ...DEFAULT_PROMPT_GUIDELINES,
48
- `If todoThinking is enabled, set \`thinking\` on every planned task during create/batch_create (or update); choose from ${levels.join(", ")}. Use higher thinking for investigation, hard debugging, risky edits, or review; use lower/off for mechanical steps and the final report. Never leave it unset in a non-trivial plan.`,
73
+ `If todoThinking is enabled, set \`thinking\` on every planned task during create/batch_create (or update); choose from ${levels.join(", ")}. Use higher thinking for investigation, hard debugging, risky edits, or review; use ${lowEffortWording} for mechanical steps and the final report. Never leave it unset in a non-trivial plan.`,
49
74
  ],
50
75
  };
51
76
  }
52
77
 
78
+ function normalizeTodoThinkingLevelForModel(model: unknown, level: TodoThinkingLevel): TodoThinkingLevel {
79
+ const available = getAvailableTodoThinkingLevels(model);
80
+ if (available.includes(level)) return level;
81
+ const requestedIndex = TODO_THINKING_LEVEL_VALUES.indexOf(level);
82
+ for (let index = requestedIndex; index < TODO_THINKING_LEVEL_VALUES.length; index += 1) {
83
+ const candidate = TODO_THINKING_LEVEL_VALUES[index];
84
+ if (candidate && available.includes(candidate)) return candidate;
85
+ }
86
+ for (let index = requestedIndex - 1; index >= 0; index -= 1) {
87
+ const candidate = TODO_THINKING_LEVEL_VALUES[index];
88
+ if (candidate && available.includes(candidate)) return candidate;
89
+ }
90
+ return available[0] ?? "off";
91
+ }
92
+
53
93
  function isAskUserToolName(toolName: string): boolean {
54
94
  return ASK_USER_TOOL_NAMES.has(toolName);
55
95
  }
@@ -140,13 +180,15 @@ export default function (pi: ExtensionAPI) {
140
180
  let settledNudgeEligible = false;
141
181
 
142
182
  function registerTodoToolWithCurrentPrompt(): void {
183
+ const availableThinkingLevels = todoThinkingEnabled ? getAvailableTodoThinkingLevels(currentModel) : undefined;
143
184
  const thinkingPrompt = todoThinkingEnabled ? buildThinkingPromptParts(currentModel) : {};
144
185
  registerTodoTool(pi, {
145
186
  ...thinkingPrompt,
187
+ ...(availableThinkingLevels ? { parameters: todoParamsSchemaForThinkingLevels(availableThinkingLevels) } : {}),
146
188
  prepareMutation: (state, _ctx, info) => {
147
189
  if (!todoThinkingEnabled) return info.params;
148
- if (info.action === "update") return prepareTodoThinkingMutation(state, info.params);
149
- if (info.action === "batch_update") {
190
+ if (info.action === "create" || info.action === "update") return prepareTodoThinkingMutation(state, info.params);
191
+ if (info.action === "batch_create" || info.action === "batch_update") {
150
192
  return {
151
193
  ...info.params,
152
194
  items: (info.params.items ?? []).map((item) => prepareTodoThinkingMutation(state, item)),
@@ -194,20 +236,29 @@ export default function (pi: ExtensionAPI) {
194
236
  }
195
237
 
196
238
  function prepareTodoThinkingMutation(state: ReturnType<typeof getState>, params: TaskMutationParams): TaskMutationParams {
197
- if (params.id === undefined) return params;
198
- const current = state.tasks.find((task) => task.id === params.id);
199
- if (!current) return params;
200
- const nextStatus = params.status ?? current.status;
201
- const nextThinking = params.thinking ?? current.thinking;
239
+ let nextParams = params;
240
+ if (params.thinking !== undefined) {
241
+ const normalized = normalizeTodoThinkingLevelForModel(currentModel, params.thinking);
242
+ if (normalized !== params.thinking) nextParams = { ...nextParams, thinking: normalized };
243
+ }
244
+ if (nextParams.id === undefined) return nextParams;
245
+ const current = state.tasks.find((task) => task.id === nextParams.id);
246
+ if (!current) return nextParams;
247
+ const nextStatus = nextParams.status ?? current.status;
248
+ if (nextStatus === "in_progress" && nextParams.thinking === undefined && current.thinking !== undefined) {
249
+ const normalized = normalizeTodoThinkingLevelForModel(currentModel, current.thinking);
250
+ if (normalized !== current.thinking) nextParams = { ...nextParams, thinking: normalized };
251
+ }
252
+ const nextThinking = nextParams.thinking ?? current.thinking;
202
253
  const shouldCapturePreviousThinking =
203
- nextStatus === "in_progress" && nextThinking !== undefined && (current.status !== "in_progress" || params.thinking !== undefined);
204
- if (!shouldCapturePreviousThinking) return params;
254
+ nextStatus === "in_progress" && nextThinking !== undefined && (current.status !== "in_progress" || nextParams.thinking !== undefined);
255
+ if (!shouldCapturePreviousThinking) return nextParams;
205
256
  const currentThinking = getCurrentThinkingLevel();
206
- if (!currentThinking) return params;
257
+ if (!currentThinking) return nextParams;
207
258
  return {
208
- ...params,
259
+ ...nextParams,
209
260
  metadata: {
210
- ...(params.metadata ?? {}),
261
+ ...(nextParams.metadata ?? {}),
211
262
  [TODO_THINKING_RESTORE_METADATA_KEY]: currentThinking,
212
263
  },
213
264
  };
@@ -233,7 +284,8 @@ export default function (pi: ExtensionAPI) {
233
284
  const previous = getRememberedThinking(taskId, state);
234
285
  if (!previous) return;
235
286
  rememberedThinkingByTaskId.delete(taskId);
236
- if (getCurrentThinkingLevel() !== previous) setTodoThinkingLevel(previous);
287
+ const restored = normalizeTodoThinkingLevelForModel(currentModel, previous);
288
+ if (getCurrentThinkingLevel() !== restored) setTodoThinkingLevel(restored);
237
289
  }
238
290
 
239
291
  function restoreInactiveTodoThinking(state: ReturnType<typeof getState>): void {
@@ -91,6 +91,7 @@ interface TodoToolHooks {
91
91
  interface TodoToolRegistrationOptions extends TodoToolHooks {
92
92
  promptSnippet?: string;
93
93
  promptGuidelines?: string[];
94
+ parameters?: typeof TodoParamsSchema;
94
95
  }
95
96
 
96
97
  type TodoStateEventContext = { sessionManager?: { getSessionFile?: () => unknown; getSessionId?: () => unknown } };
@@ -391,7 +392,7 @@ export function registerTodoTool(pi: ExtensionAPI, hooks: TodoToolRegistrationOp
391
392
  label: TOOL_LABEL,
392
393
  promptSnippet: hooks.promptSnippet ?? DEFAULT_PROMPT_SNIPPET,
393
394
  promptGuidelines: hooks.promptGuidelines ?? DEFAULT_PROMPT_GUIDELINES,
394
- parameters: TodoParamsSchema,
395
+ parameters: hooks.parameters ?? TodoParamsSchema,
395
396
 
396
397
  async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
397
398
  activateTodoStateScope(_ctx);
@@ -24,7 +24,8 @@ export const MSG_NO_TODOS = "No todos yet. Ask the agent to add some!";
24
24
  // ---------------------------------------------------------------------------
25
25
 
26
26
  export type TaskStatus = "pending" | "in_progress" | "deferred" | "completed" | "deleted";
27
- export type TodoThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
27
+ export const TODO_THINKING_LEVEL_VALUES = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
28
+ export type TodoThinkingLevel = (typeof TODO_THINKING_LEVEL_VALUES)[number];
28
29
 
29
30
  export type TaskAction = "create" | "update" | "batch_create" | "batch_update" | "list" | "get" | "delete" | "clear" | "export" | "import";
30
31
 
@@ -104,7 +105,7 @@ export const TodoParamsSchema = Type.Object({
104
105
  }),
105
106
  ),
106
107
  thinking: Type.Optional(
107
- StringEnum(["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const, {
108
+ StringEnum(TODO_THINKING_LEVEL_VALUES, {
108
109
  description: "Per-task thinking level used when todoThinking is enabled and this task is in_progress",
109
110
  }),
110
111
  ),
@@ -164,4 +165,20 @@ export const TodoParamsSchema = Type.Object({
164
165
  replace: Type.Optional(Type.Boolean({ description: "For import/create/batch_create, replace existing tasks instead of appending. Use batch_create with replace:true when starting a new plan that supersedes old unfinished todos. Default: false." })),
165
166
  });
166
167
 
168
+ export function todoParamsSchemaForThinkingLevels(levels: readonly TodoThinkingLevel[]): typeof TodoParamsSchema {
169
+ const supported = TODO_THINKING_LEVEL_VALUES.filter((level) => levels.includes(level));
170
+ const effective = supported.length > 0 ? supported : ["off"];
171
+ return {
172
+ ...TodoParamsSchema,
173
+ properties: {
174
+ ...TodoParamsSchema.properties,
175
+ thinking: Type.Optional(
176
+ StringEnum(effective as unknown as typeof TODO_THINKING_LEVEL_VALUES, {
177
+ description: "Per-task thinking level used when todoThinking is enabled and this task is in_progress",
178
+ }),
179
+ ),
180
+ },
181
+ } as typeof TodoParamsSchema;
182
+ }
183
+
167
184
  export type TodoParams = Static<typeof TodoParamsSchema>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ui-extend",
3
- "version": "1.0.12",
3
+ "version": "1.0.14",
4
4
  "description": "Pix: a workspace-first terminal UI for Pi with tabs, readable tool activity, voice input, and bundled agent tools.",
5
5
  "private": false,
6
6
  "repository": {
@@ -16,6 +16,22 @@
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."
@@ -812,6 +828,20 @@
812
828
  },
813
829
  "description": "User-defined slash commands."
814
830
  },
831
+ "secretFirewall": {
832
+ "type": "object",
833
+ "properties": {
834
+ "sessionHygiene": {
835
+ "type": "boolean",
836
+ "description": "Redact detected secret material from tool results and completed messages before it remains in session history."
837
+ },
838
+ "notify": {
839
+ "type": "boolean",
840
+ "description": "Show a warning when one or more secrets are redacted. Secret values are never included in notifications."
841
+ }
842
+ },
843
+ "description": "Settings for the opt-in credential-firewall module."
844
+ },
815
845
  "lsp": {
816
846
  "type": "object",
817
847
  "properties": {