pi-agent-extensions 0.3.1 → 0.3.3

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.
@@ -12,6 +12,7 @@
12
12
 
13
13
  import { complete, type Model, type Api, type UserMessage } from "@mariozechner/pi-ai";
14
14
  import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
15
+ import { getRequestAuth, hasRequestAuth } from "../shared/auth.js";
15
16
  import { BorderedLoader } from "@mariozechner/pi-coding-agent";
16
17
  import {
17
18
  type Component,
@@ -77,15 +78,17 @@ async function selectExtractionModel(
77
78
  currentModel: Model<Api>,
78
79
  modelRegistry: {
79
80
  find: (provider: string, modelId: string) => Model<Api> | undefined;
80
- getApiKey: (model: Model<Api>) => Promise<string | undefined>;
81
+ getApiKeyAndHeaders: (
82
+ model: Model<Api>,
83
+ ) => Promise<
84
+ | { ok: true; apiKey?: string; headers?: Record<string, string> }
85
+ | { ok: false; error: string }
86
+ >;
81
87
  },
82
88
  ): Promise<Model<Api>> {
83
89
  const codexModel = modelRegistry.find("openai-codex", CODEX_MODEL_ID);
84
- if (codexModel) {
85
- const apiKey = await modelRegistry.getApiKey(codexModel);
86
- if (apiKey) {
87
- return codexModel;
88
- }
90
+ if (codexModel && (await hasRequestAuth(modelRegistry, codexModel))) {
91
+ return codexModel;
89
92
  }
90
93
 
91
94
  const haikuModel = modelRegistry.find("anthropic", HAIKU_MODEL_ID);
@@ -93,8 +96,7 @@ async function selectExtractionModel(
93
96
  return currentModel;
94
97
  }
95
98
 
96
- const apiKey = await modelRegistry.getApiKey(haikuModel);
97
- if (!apiKey) {
99
+ if (!(await hasRequestAuth(modelRegistry, haikuModel))) {
98
100
  return currentModel;
99
101
  }
100
102
 
@@ -457,7 +459,7 @@ export default function (pi: ExtensionAPI) {
457
459
  loader.onAbort = () => done(null);
458
460
 
459
461
  const doExtract = async () => {
460
- const apiKey = await ctx.modelRegistry.getApiKey(extractionModel);
462
+ const requestAuth = await getRequestAuth(ctx.modelRegistry, extractionModel);
461
463
  const userMessage: UserMessage = {
462
464
  role: "user",
463
465
  content: [{ type: "text", text: lastAssistantText! }],
@@ -467,7 +469,7 @@ export default function (pi: ExtensionAPI) {
467
469
  const response = await complete(
468
470
  extractionModel,
469
471
  { systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
470
- { apiKey, signal: loader.signal },
472
+ { ...requestAuth, signal: loader.signal },
471
473
  );
472
474
 
473
475
  if (response.stopReason === "aborted") {
@@ -40,7 +40,7 @@ export async function createPendingFile(params: AskUserParams, ctx: ExtensionCon
40
40
  };
41
41
 
42
42
  // Write file
43
- await writeFile(pendingFile, JSON.stringify(pending, null, 2), "utf-8");
43
+ await writeFile(pendingFile, JSON.stringify(pending, null, 2), { encoding: "utf-8", mode: 0o600 });
44
44
 
45
45
  return pendingFile;
46
46
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * /context
2
+ * /context-simple
3
3
  *
4
4
  * Small TUI view showing what's loaded/available:
5
5
  * - extensions (best-effort from registered extension slash commands)
@@ -470,8 +470,8 @@ export default function contextExtension(pi: ExtensionAPI) {
470
470
  }
471
471
  });
472
472
 
473
- pi.registerCommand("context", {
474
- description: "Show loaded context overview",
473
+ pi.registerCommand("context-simple", {
474
+ description: "Show loaded context overview",
475
475
  handler: async (_args, ctx: ExtensionCommandContext) => {
476
476
  const commands = pi.getCommands();
477
477
  const extensionCmds = commands.filter((c) => c.source === "extension");
@@ -38,9 +38,11 @@
38
38
  import type { ExtensionAPI, ExtensionContext, TurnEndEvent, MessageRenderer } from "@mariozechner/pi-coding-agent";
39
39
  import { getMarkdownTheme } from "@mariozechner/pi-coding-agent";
40
40
  import { complete, type Model, type Api, type UserMessage, type TextContent } from "@mariozechner/pi-ai";
41
+ import { getRequestAuth, hasRequestAuth } from "../shared/auth.js";
41
42
  import { StringEnum } from "@mariozechner/pi-ai";
42
43
  import { Box, Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
43
44
  import { Type } from "@sinclair/typebox";
45
+ import crypto from "node:crypto";
44
46
  import { promises as fs } from "node:fs";
45
47
  import * as net from "node:net";
46
48
  import * as os from "node:os";
@@ -155,20 +157,19 @@ async function selectSummarizationModel(
155
157
  currentModel: Model<Api> | undefined,
156
158
  modelRegistry: {
157
159
  find: (provider: string, modelId: string) => Model<Api> | undefined;
158
- getApiKey: (model: Model<Api>) => Promise<string | undefined>;
160
+ getApiKeyAndHeaders: (
161
+ model: Model<Api>,
162
+ ) => Promise<
163
+ | { ok: true; apiKey?: string; headers?: Record<string, string> }
164
+ | { ok: false; error: string }
165
+ >;
159
166
  },
160
167
  ): Promise<Model<Api> | undefined> {
161
168
  const codexModel = modelRegistry.find("openai-codex", CODEX_MODEL_ID);
162
- if (codexModel) {
163
- const apiKey = await modelRegistry.getApiKey(codexModel);
164
- if (apiKey) return codexModel;
165
- }
169
+ if (codexModel && (await hasRequestAuth(modelRegistry, codexModel))) return codexModel;
166
170
 
167
171
  const haikuModel = modelRegistry.find("anthropic", HAIKU_MODEL_ID);
168
- if (haikuModel) {
169
- const apiKey = await modelRegistry.getApiKey(haikuModel);
170
- if (apiKey) return haikuModel;
171
- }
172
+ if (haikuModel && (await hasRequestAuth(modelRegistry, haikuModel))) return haikuModel;
172
173
 
173
174
  return currentModel;
174
175
  }
@@ -603,7 +604,7 @@ async function handleCommand(
603
604
  // Subscribe to turn_end
604
605
  if (command.type === "subscribe") {
605
606
  if (command.event === "turn_end") {
606
- const subscriptionId = id ?? `sub_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
607
+ const subscriptionId = id ?? `sub_${crypto.randomBytes(16).toString("hex")}`;
607
608
  state.turnEndSubscriptions.push({ socket, subscriptionId });
608
609
 
609
610
  const cleanup = () => {
@@ -645,9 +646,9 @@ async function handleCommand(
645
646
  return;
646
647
  }
647
648
 
648
- const apiKey = await ctx.modelRegistry.getApiKey(model);
649
- if (!apiKey) {
650
- respond(false, "get_summary", undefined, "No API key available for summarization model");
649
+ const requestAuth = await getRequestAuth(ctx.modelRegistry, model);
650
+ if (!requestAuth) {
651
+ respond(false, "get_summary", undefined, "No auth available for summarization model");
651
652
  return;
652
653
  }
653
654
 
@@ -665,7 +666,7 @@ async function handleCommand(
665
666
  const response = await complete(
666
667
  model,
667
668
  { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: [userMessage] },
668
- { apiKey },
669
+ requestAuth,
669
670
  );
670
671
 
671
672
  if (response.stopReason === "aborted" || response.stopReason === "error") {
@@ -1004,10 +1005,6 @@ export default function (pi: ExtensionAPI) {
1004
1005
  await refreshServer(ctx);
1005
1006
  });
1006
1007
 
1007
- pi.on("session_switch", async (_event, ctx) => {
1008
- await refreshServer(ctx);
1009
- });
1010
-
1011
1008
  pi.on("session_shutdown", async () => {
1012
1009
  if (state.aliasTimer) {
1013
1010
  clearInterval(state.aliasTimer);
@@ -230,8 +230,4 @@ export default function (pi: ExtensionAPI) {
230
230
  pi.on("session_start", (_event, ctx) => {
231
231
  applyEditorWithHistory(pi, ctx);
232
232
  });
233
-
234
- pi.on("session_switch", (_event, ctx) => {
235
- applyEditorWithHistory(pi, ctx);
236
- });
237
233
  }
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { spawnSync } from "node:child_process";
10
+ import crypto from "node:crypto";
10
11
  import {
11
12
  existsSync,
12
13
  mkdtempSync,
@@ -693,10 +694,10 @@ const openPath = async (pi: ExtensionAPI, ctx: ExtensionContext, target: FileEnt
693
694
  };
694
695
 
695
696
  const openExternalEditor = (tui: TUI, editorCmd: string, content: string): string | null => {
696
- const tmpFile = path.join(os.tmpdir(), `pi-files-edit-${Date.now()}.txt`);
697
+ const tmpFile = path.join(os.tmpdir(), `pi-files-edit-${crypto.randomBytes(16).toString("hex")}.txt`);
697
698
 
698
699
  try {
699
- writeFileSync(tmpFile, content, "utf8");
700
+ writeFileSync(tmpFile, content, { encoding: "utf8", mode: 0o600 });
700
701
  tui.stop();
701
702
 
702
703
  const [editor, ...editorArgs] = editorCmd.split(" ");
@@ -813,15 +814,15 @@ const openDiff = async (pi: ExtensionAPI, ctx: ExtensionContext, target: FileEnt
813
814
  ctx.ui.notify(errorMessage, "error");
814
815
  return;
815
816
  }
816
- writeFileSync(tmpFile, result.stdout ?? "", "utf8");
817
+ writeFileSync(tmpFile, result.stdout ?? "", { encoding: "utf8", mode: 0o600 });
817
818
  } else {
818
- writeFileSync(tmpFile, "", "utf8");
819
+ writeFileSync(tmpFile, "", { encoding: "utf8", mode: 0o600 });
819
820
  }
820
821
 
821
822
  let workingPath = target.resolvedPath;
822
823
  if (!existsSync(target.resolvedPath)) {
823
824
  workingPath = path.join(tmpDir, `pi-files-working-${path.basename(target.displayPath)}`);
824
- writeFileSync(workingPath, "", "utf8");
825
+ writeFileSync(workingPath, "", { encoding: "utf8", mode: 0o600 });
825
826
  }
826
827
 
827
828
  const openResult = await pi.exec("code", ["--diff", tmpFile, workingPath], { cwd: gitRoot });
@@ -22,6 +22,7 @@ import {
22
22
  serializeConversation,
23
23
  } from "@mariozechner/pi-coding-agent";
24
24
 
25
+ import { getRequestAuthOrThrow } from "../shared/auth.js";
25
26
  import { loadConfig, validateGoal } from "./config.js";
26
27
  import { ProgressLoader, EXTRACTION_PHASES } from "./progress.js";
27
28
  import {
@@ -290,7 +291,7 @@ async function doExtraction(
290
291
  model: Model<any>,
291
292
  signal?: AbortSignal,
292
293
  ): Promise<ExtractionResult> {
293
- const apiKey = await ctx.modelRegistry.getApiKey(model);
294
+ const requestAuth = await getRequestAuthOrThrow(ctx.modelRegistry, model);
294
295
 
295
296
  // Build user message
296
297
  const userMessage: Message = {
@@ -305,7 +306,7 @@ async function doExtraction(
305
306
  const response = await complete(
306
307
  model,
307
308
  { systemPrompt: EXTRACTION_SYSTEM_PROMPT, messages: [userMessage] },
308
- { apiKey, signal },
309
+ { ...requestAuth, signal },
309
310
  );
310
311
 
311
312
  if (response.stopReason === "aborted") {
@@ -347,7 +348,7 @@ async function doExtraction(
347
348
  systemPrompt: EXTRACTION_SYSTEM_PROMPT,
348
349
  messages: [userMessage, assistantMessage, retryMessage],
349
350
  },
350
- { apiKey, signal },
351
+ { ...requestAuth, signal },
351
352
  );
352
353
 
353
354
  if (retryResponse.stopReason === "aborted") {
@@ -383,7 +384,7 @@ async function doExtractionWithPhases(
383
384
  signal: AbortSignal,
384
385
  onPhase: (phase: string) => void,
385
386
  ): Promise<ExtractionResult> {
386
- const apiKey = await ctx.modelRegistry.getApiKey(model);
387
+ const requestAuth = await getRequestAuthOrThrow(ctx.modelRegistry, model);
387
388
 
388
389
  // Phase 1: Analyzing conversation
389
390
  onPhase(EXTRACTION_PHASES[0]);
@@ -404,7 +405,7 @@ async function doExtractionWithPhases(
404
405
  const response = await complete(
405
406
  model,
406
407
  { systemPrompt: EXTRACTION_SYSTEM_PROMPT, messages: [userMessage] },
407
- { apiKey, signal },
408
+ { ...requestAuth, signal },
408
409
  );
409
410
 
410
411
  if (response.stopReason === "aborted") {
@@ -451,7 +452,7 @@ async function doExtractionWithPhases(
451
452
  systemPrompt: EXTRACTION_SYSTEM_PROMPT,
452
453
  messages: [userMessage, assistantMessage, retryMessage],
453
454
  },
454
- { apiKey, signal },
455
+ { ...requestAuth, signal },
455
456
  );
456
457
 
457
458
  if (retryResponse.stopReason === "aborted") {
@@ -8,10 +8,11 @@
8
8
 
9
9
  import { Type } from "@sinclair/typebox";
10
10
  import { complete, type Api, type Model, type UserMessage } from "@mariozechner/pi-ai";
11
- import type { ExtensionAPI, ExtensionContext, SessionSwitchEvent } from "@mariozechner/pi-coding-agent";
11
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
12
12
  import { compact } from "@mariozechner/pi-coding-agent";
13
13
  import { Container, type SelectItem, SelectList, Text } from "@mariozechner/pi-tui";
14
14
  import { DynamicBorder } from "@mariozechner/pi-coding-agent";
15
+ import { getRequestAuth, hasRequestAuth } from "../shared/auth.js";
15
16
 
16
17
  type LoopMode = "tests" | "custom" | "self";
17
18
 
@@ -87,22 +88,22 @@ function getConditionText(mode: LoopMode, condition?: string): string {
87
88
 
88
89
  async function selectSummaryModel(
89
90
  ctx: ExtensionContext,
90
- ): Promise<{ model: Model<Api>; apiKey: string } | null> {
91
+ ): Promise<{ model: Model<Api>; requestAuth: { apiKey?: string; headers?: Record<string, string> } } | null> {
91
92
  if (!ctx.model) return null;
92
93
 
93
94
  if (ctx.model.provider === "anthropic") {
94
95
  const haikuModel = ctx.modelRegistry.find("anthropic", HAIKU_MODEL_ID);
95
- if (haikuModel) {
96
- const apiKey = await ctx.modelRegistry.getApiKey(haikuModel);
97
- if (apiKey) {
98
- return { model: haikuModel, apiKey };
96
+ if (haikuModel && (await hasRequestAuth(ctx.modelRegistry, haikuModel))) {
97
+ const requestAuth = await getRequestAuth(ctx.modelRegistry, haikuModel);
98
+ if (requestAuth) {
99
+ return { model: haikuModel, requestAuth };
99
100
  }
100
101
  }
101
102
  }
102
103
 
103
- const apiKey = await ctx.modelRegistry.getApiKey(ctx.model);
104
- if (!apiKey) return null;
105
- return { model: ctx.model, apiKey };
104
+ const requestAuth = await getRequestAuth(ctx.modelRegistry, ctx.model);
105
+ if (!requestAuth) return null;
106
+ return { model: ctx.model, requestAuth };
106
107
  }
107
108
 
108
109
  async function summarizeBreakoutCondition(
@@ -124,7 +125,7 @@ async function summarizeBreakoutCondition(
124
125
  const response = await complete(
125
126
  selection.model,
126
127
  { systemPrompt: SUMMARY_SYSTEM_PROMPT, messages: [userMessage] },
127
- { apiKey: selection.apiKey },
128
+ selection.requestAuth,
128
129
  );
129
130
 
130
131
  if (response.stopReason === "aborted" || response.stopReason === "error") {
@@ -400,15 +401,22 @@ export default function loopExtension(pi: ExtensionAPI): void {
400
401
 
401
402
  pi.on("session_before_compact", async (event, ctx) => {
402
403
  if (!loopState.active || !loopState.mode || !ctx.model) return;
403
- const apiKey = await ctx.modelRegistry.getApiKey(ctx.model);
404
- if (!apiKey) return;
404
+ const requestAuth = await getRequestAuth(ctx.modelRegistry, ctx.model);
405
+ if (!requestAuth) return;
405
406
 
406
407
  const instructionParts = [event.customInstructions, getCompactionInstructions(loopState.mode, loopState.condition)]
407
408
  .filter(Boolean)
408
409
  .join("\n\n");
409
410
 
410
411
  try {
411
- const compaction = await compact(event.preparation, ctx.model, apiKey, instructionParts, event.signal);
412
+ const compaction = await compact(
413
+ event.preparation,
414
+ ctx.model,
415
+ requestAuth.apiKey ?? "",
416
+ requestAuth.headers,
417
+ instructionParts,
418
+ event.signal,
419
+ );
412
420
  return { compaction };
413
421
  } catch (error) {
414
422
  if (ctx.hasUI) {
@@ -439,8 +447,4 @@ export default function loopExtension(pi: ExtensionAPI): void {
439
447
  pi.on("session_start", async (_event, ctx) => {
440
448
  await restoreLoopState(ctx);
441
449
  });
442
-
443
- pi.on("session_switch", async (_event: SessionSwitchEvent, ctx) => {
444
- await restoreLoopState(ctx);
445
- });
446
450
  }
@@ -69,7 +69,7 @@ export function loadModelsConfigSync(): NvidiaModelsConfig | null {
69
69
  export async function saveModelsConfig(config: NvidiaModelsConfig): Promise<void> {
70
70
  const configPath = getConfigPath();
71
71
  await fs.mkdir(path.dirname(configPath), { recursive: true });
72
- await fs.writeFile(configPath, JSON.stringify(config, null, 2), "utf-8");
72
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2), { encoding: "utf-8", mode: 0o600 });
73
73
  }
74
74
 
75
75
  /** Parse model IDs from editor text, ignoring comments and blank lines.
@@ -485,10 +485,6 @@ export default function reviewExtension(pi: ExtensionAPI) {
485
485
  applyReviewState(ctx);
486
486
  });
487
487
 
488
- pi.on("session_switch", (_event, ctx) => {
489
- applyReviewState(ctx);
490
- });
491
-
492
488
  pi.on("session_tree", (_event, ctx) => {
493
489
  applyReviewState(ctx);
494
490
  });
@@ -0,0 +1,40 @@
1
+ import type { Api, Model, ProviderStreamOptions } from "@mariozechner/pi-ai";
2
+
3
+ export type RequestAuth = Pick<ProviderStreamOptions, "apiKey" | "headers">;
4
+
5
+ type ModelRegistryWithRequestAuth = {
6
+ getApiKeyAndHeaders: (
7
+ model: Model<Api>,
8
+ ) => Promise<
9
+ | { ok: true; apiKey?: string; headers?: Record<string, string> }
10
+ | { ok: false; error: string }
11
+ >;
12
+ };
13
+
14
+ export async function getRequestAuth(
15
+ modelRegistry: ModelRegistryWithRequestAuth,
16
+ model: Model<Api>,
17
+ ): Promise<RequestAuth | undefined> {
18
+ const auth = await modelRegistry.getApiKeyAndHeaders(model);
19
+ if (!auth.ok) return undefined;
20
+ return { apiKey: auth.apiKey, headers: auth.headers };
21
+ }
22
+
23
+ export async function getRequestAuthOrThrow(
24
+ modelRegistry: ModelRegistryWithRequestAuth,
25
+ model: Model<Api>,
26
+ ): Promise<RequestAuth> {
27
+ const auth = await modelRegistry.getApiKeyAndHeaders(model);
28
+ if (!auth.ok) {
29
+ throw new Error(auth.error);
30
+ }
31
+ return { apiKey: auth.apiKey, headers: auth.headers };
32
+ }
33
+
34
+ export async function hasRequestAuth(
35
+ modelRegistry: ModelRegistryWithRequestAuth,
36
+ model: Model<Api>,
37
+ ): Promise<boolean> {
38
+ const auth = await modelRegistry.getApiKeyAndHeaders(model);
39
+ return auth.ok;
40
+ }
@@ -923,7 +923,7 @@ async function readTodoFile(filePath: string, idFallback: string): Promise<TodoR
923
923
  }
924
924
 
925
925
  async function writeTodoFile(filePath: string, todo: TodoRecord) {
926
- await fs.writeFile(filePath, serializeTodo(todo), "utf8");
926
+ await fs.writeFile(filePath, serializeTodo(todo), { encoding: "utf8", mode: 0o600 });
927
927
  }
928
928
 
929
929
  async function generateTodoId(todosDir: string): Promise<string> {
@@ -955,14 +955,14 @@ async function acquireLock(
955
955
 
956
956
  for (let attempt = 0; attempt < 2; attempt += 1) {
957
957
  try {
958
- const handle = await fs.open(lockPath, "wx");
958
+ const handle = await fs.open(lockPath, "wx", 0o600);
959
959
  const info: LockInfo = {
960
960
  id,
961
961
  pid: process.pid,
962
962
  session,
963
963
  created_at: new Date(now).toISOString(),
964
964
  };
965
- await handle.writeFile(JSON.stringify(info, null, 2), "utf8");
965
+ await handle.writeFile(JSON.stringify(info, null, 2), { encoding: "utf8", mode: 0o600 });
966
966
  await handle.close();
967
967
  return async () => {
968
968
  try {
@@ -263,7 +263,7 @@ async function saveStateToSettings(): Promise<void> {
263
263
  } satisfies PersistedWhimsyConfig;
264
264
 
265
265
  await fs.mkdir(dir, { recursive: true });
266
- await fs.writeFile(settingsPath, JSON.stringify(parsed, null, 2), "utf-8");
266
+ await fs.writeFile(settingsPath, JSON.stringify(parsed, null, 2), { encoding: "utf-8", mode: 0o600 });
267
267
  }
268
268
 
269
269
  async function ensureStateLoaded(): Promise<void> {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-agent-extensions",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "Collection of extensions for pi coding agent",
5
5
  "type": "module",
6
6
  "repository": {
@@ -62,7 +62,7 @@
62
62
  },
63
63
  "devDependencies": {
64
64
  "@sinclair/typebox": "^0.34.48",
65
- "tsx": "^4.19.0"
65
+ "tsx": "^4.21.0"
66
66
  },
67
67
  "scripts": {
68
68
  "test": "node --import tsx --test 'tests/**/*.test.ts' 'tests/*.test.ts'"