@oh-my-pi/pi-coding-agent 3.32.0 → 3.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CHANGELOG.md +49 -9
  2. package/README.md +12 -0
  3. package/docs/custom-tools.md +1 -1
  4. package/docs/extensions.md +4 -4
  5. package/docs/hooks.md +2 -2
  6. package/docs/sdk.md +4 -8
  7. package/examples/custom-tools/README.md +2 -2
  8. package/examples/extensions/README.md +1 -1
  9. package/examples/extensions/todo.ts +1 -1
  10. package/examples/hooks/custom-compaction.ts +4 -2
  11. package/examples/hooks/handoff.ts +1 -1
  12. package/examples/hooks/qna.ts +1 -1
  13. package/examples/sdk/02-custom-model.ts +1 -1
  14. package/examples/sdk/README.md +1 -1
  15. package/package.json +5 -5
  16. package/src/capability/ssh.ts +42 -0
  17. package/src/cli/file-processor.ts +1 -1
  18. package/src/cli/list-models.ts +1 -1
  19. package/src/core/agent-session.ts +21 -6
  20. package/src/core/auth-storage.ts +1 -1
  21. package/src/core/compaction/branch-summarization.ts +2 -2
  22. package/src/core/compaction/compaction.ts +2 -2
  23. package/src/core/compaction/utils.ts +1 -1
  24. package/src/core/custom-tools/types.ts +1 -1
  25. package/src/core/extensions/runner.ts +1 -1
  26. package/src/core/extensions/types.ts +1 -1
  27. package/src/core/extensions/wrapper.ts +1 -1
  28. package/src/core/file-mentions.ts +147 -5
  29. package/src/core/hooks/runner.ts +2 -2
  30. package/src/core/hooks/types.ts +1 -1
  31. package/src/core/index.ts +11 -0
  32. package/src/core/messages.ts +1 -1
  33. package/src/core/model-registry.ts +1 -1
  34. package/src/core/model-resolver.ts +9 -4
  35. package/src/core/sdk.ts +26 -2
  36. package/src/core/session-manager.ts +3 -2
  37. package/src/core/settings-manager.ts +70 -0
  38. package/src/core/ssh/connection-manager.ts +466 -0
  39. package/src/core/ssh/ssh-executor.ts +190 -0
  40. package/src/core/ssh/sshfs-mount.ts +162 -0
  41. package/src/core/ssh-executor.ts +5 -0
  42. package/src/core/system-prompt.ts +424 -1
  43. package/src/core/title-generator.ts +109 -55
  44. package/src/core/tools/index.test.ts +1 -0
  45. package/src/core/tools/index.ts +3 -0
  46. package/src/core/tools/output.ts +37 -2
  47. package/src/core/tools/read.ts +24 -11
  48. package/src/core/tools/renderers.ts +2 -0
  49. package/src/core/tools/ssh.ts +302 -0
  50. package/src/core/tools/task/index.ts +1 -1
  51. package/src/core/tools/task/render.ts +10 -16
  52. package/src/core/tools/task/types.ts +1 -1
  53. package/src/core/tools/task/worker.ts +1 -1
  54. package/src/core/voice.ts +1 -1
  55. package/src/discovery/index.ts +3 -0
  56. package/src/discovery/ssh.ts +162 -0
  57. package/src/main.ts +2 -1
  58. package/src/modes/interactive/components/assistant-message.ts +1 -1
  59. package/src/modes/interactive/components/bash-execution.ts +9 -10
  60. package/src/modes/interactive/components/custom-message.ts +1 -1
  61. package/src/modes/interactive/components/footer.ts +1 -1
  62. package/src/modes/interactive/components/hook-message.ts +1 -1
  63. package/src/modes/interactive/components/model-selector.ts +1 -1
  64. package/src/modes/interactive/components/oauth-selector.ts +1 -1
  65. package/src/modes/interactive/components/status-line.ts +1 -1
  66. package/src/modes/interactive/components/tree-selector.ts +9 -12
  67. package/src/modes/interactive/interactive-mode.ts +5 -2
  68. package/src/modes/interactive/theme/theme.ts +2 -2
  69. package/src/modes/print-mode.ts +1 -1
  70. package/src/modes/rpc/rpc-client.ts +1 -1
  71. package/src/modes/rpc/rpc-types.ts +1 -1
  72. package/src/prompts/system-prompt.md +4 -0
  73. package/src/prompts/tools/ssh.md +74 -0
  74. package/src/utils/image-resize.ts +1 -1
@@ -9,14 +9,148 @@
9
9
  import path from "node:path";
10
10
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
11
11
  import type { FileMentionMessage } from "./messages";
12
+ import { resolveReadPath } from "./tools/path-utils";
13
+ import { formatAge } from "./tools/render-utils";
14
+ import { DEFAULT_MAX_BYTES, formatSize, truncateHead, truncateStringToBytesFromStart } from "./tools/truncate";
12
15
 
13
16
  /** Regex to match @filepath patterns in text */
14
- const FILE_MENTION_REGEX = /@((?:[^\s@]+\/)*[^\s@]+\.[a-zA-Z0-9]+)/g;
17
+ const FILE_MENTION_REGEX = /@([^\s@]+)/g;
18
+ const LEADING_PUNCTUATION_REGEX = /^[`"'([{<]+/;
19
+ const TRAILING_PUNCTUATION_REGEX = /[)\]}>.,;:!?"'`]+$/;
20
+ const MENTION_BOUNDARY_REGEX = /[\s([{<"'`]/;
21
+ const DEFAULT_DIR_LIMIT = 500;
22
+
23
+ function isMentionBoundary(text: string, index: number): boolean {
24
+ if (index === 0) return true;
25
+ return MENTION_BOUNDARY_REGEX.test(text[index - 1]);
26
+ }
27
+
28
+ function sanitizeMentionPath(rawPath: string): string | null {
29
+ let cleaned = rawPath.trim();
30
+ cleaned = cleaned.replace(LEADING_PUNCTUATION_REGEX, "");
31
+ cleaned = cleaned.replace(TRAILING_PUNCTUATION_REGEX, "");
32
+ cleaned = cleaned.trim();
33
+ return cleaned.length > 0 ? cleaned : null;
34
+ }
35
+
36
+ function buildTextOutput(textContent: string): { output: string; lineCount: number } {
37
+ const allLines = textContent.split("\n");
38
+ const totalFileLines = allLines.length;
39
+ const truncation = truncateHead(textContent);
40
+
41
+ if (truncation.firstLineExceedsLimit) {
42
+ const firstLine = allLines[0] ?? "";
43
+ const firstLineBytes = Buffer.byteLength(firstLine, "utf-8");
44
+ const snippet = truncateStringToBytesFromStart(firstLine, DEFAULT_MAX_BYTES);
45
+ let outputText = snippet.text;
46
+
47
+ if (outputText.length > 0) {
48
+ outputText += `\n\n[Line 1 is ${formatSize(firstLineBytes)}, exceeds ${formatSize(
49
+ DEFAULT_MAX_BYTES,
50
+ )} limit. Showing first ${formatSize(snippet.bytes)} of the line.]`;
51
+ } else {
52
+ outputText = `[Line 1 is ${formatSize(firstLineBytes)}, exceeds ${formatSize(
53
+ DEFAULT_MAX_BYTES,
54
+ )} limit. Unable to display a valid UTF-8 snippet.]`;
55
+ }
56
+
57
+ return { output: outputText, lineCount: totalFileLines };
58
+ }
59
+
60
+ let outputText = truncation.content;
61
+
62
+ if (truncation.truncated) {
63
+ const endLineDisplay = truncation.outputLines;
64
+ const nextOffset = endLineDisplay + 1;
65
+
66
+ if (truncation.truncatedBy === "lines") {
67
+ outputText += `\n\n[Showing lines 1-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue]`;
68
+ } else {
69
+ outputText += `\n\n[Showing lines 1-${endLineDisplay} of ${totalFileLines} (${formatSize(
70
+ DEFAULT_MAX_BYTES,
71
+ )} limit). Use offset=${nextOffset} to continue]`;
72
+ }
73
+ }
74
+
75
+ return { output: outputText, lineCount: totalFileLines };
76
+ }
77
+
78
+ async function buildDirectoryListing(absolutePath: string): Promise<{ output: string; lineCount: number }> {
79
+ let entries: string[];
80
+ try {
81
+ entries = await Array.fromAsync(new Bun.Glob("*").scan({ cwd: absolutePath, dot: true, onlyFiles: false }));
82
+ } catch {
83
+ return { output: "(empty directory)", lineCount: 1 };
84
+ }
85
+
86
+ entries.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
87
+
88
+ const results: string[] = [];
89
+ let entryLimitReached = false;
90
+
91
+ for (const entry of entries) {
92
+ if (results.length >= DEFAULT_DIR_LIMIT) {
93
+ entryLimitReached = true;
94
+ break;
95
+ }
96
+
97
+ const fullPath = path.join(absolutePath, entry);
98
+ let suffix = "";
99
+ let age = "";
100
+
101
+ try {
102
+ const stat = await Bun.file(fullPath).stat();
103
+ if (stat.isDirectory()) {
104
+ suffix = "/";
105
+ }
106
+ const ageSeconds = Math.floor((Date.now() - stat.mtimeMs) / 1000);
107
+ age = formatAge(ageSeconds);
108
+ } catch {
109
+ continue;
110
+ }
111
+
112
+ const line = age ? `${entry}${suffix} (${age})` : `${entry}${suffix}`;
113
+ results.push(line);
114
+ }
115
+
116
+ if (results.length === 0) {
117
+ return { output: "(empty directory)", lineCount: 1 };
118
+ }
119
+
120
+ const rawOutput = results.join("\n");
121
+ const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
122
+ let output = truncation.content;
123
+
124
+ const notices: string[] = [];
125
+ if (entryLimitReached) {
126
+ notices.push(`${DEFAULT_DIR_LIMIT} entries limit reached. Use limit=${DEFAULT_DIR_LIMIT * 2} for more`);
127
+ }
128
+ if (truncation.truncated) {
129
+ notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
130
+ }
131
+ if (notices.length > 0) {
132
+ output += `\n\n[${notices.join(". ")}]`;
133
+ }
134
+
135
+ return { output, lineCount: output.split("\n").length };
136
+ }
15
137
 
16
138
  /** Extract all @filepath mentions from text */
17
139
  export function extractFileMentions(text: string): string[] {
18
140
  const matches = [...text.matchAll(FILE_MENTION_REGEX)];
19
- return [...new Set(matches.map((m) => m[1]))];
141
+ const mentions: string[] = [];
142
+
143
+ for (const match of matches) {
144
+ const index = match.index ?? 0;
145
+ if (!isMentionBoundary(text, index)) continue;
146
+
147
+ const cleaned = sanitizeMentionPath(match[1]);
148
+ if (!cleaned) continue;
149
+
150
+ mentions.push(cleaned);
151
+ }
152
+
153
+ return [...new Set(mentions)];
20
154
  }
21
155
 
22
156
  /**
@@ -29,11 +163,19 @@ export async function generateFileMentionMessages(filePaths: string[], cwd: stri
29
163
  const files: FileMentionMessage["files"] = [];
30
164
 
31
165
  for (const filePath of filePaths) {
166
+ const absolutePath = resolveReadPath(filePath, cwd);
167
+
32
168
  try {
33
- const absolutePath = path.resolve(cwd, filePath);
169
+ const stat = await Bun.file(absolutePath).stat();
170
+ if (stat.isDirectory()) {
171
+ const { output, lineCount } = await buildDirectoryListing(absolutePath);
172
+ files.push({ path: filePath, content: output, lineCount });
173
+ continue;
174
+ }
175
+
34
176
  const content = await Bun.file(absolutePath).text();
35
- const lineCount = content.split("\n").length;
36
- files.push({ path: filePath, content, lineCount });
177
+ const { output, lineCount } = buildTextOutput(content);
178
+ files.push({ path: filePath, content: output, lineCount });
37
179
  } catch {
38
180
  // File doesn't exist or isn't readable - skip silently
39
181
  }
@@ -2,8 +2,8 @@
2
2
  * Hook runner - executes hooks and manages their lifecycle.
3
3
  */
4
4
 
5
- import type { Model } from "@mariozechner/pi-ai";
6
5
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
6
+ import type { Model } from "@oh-my-pi/pi-ai";
7
7
  import { theme } from "../../modes/interactive/theme/theme";
8
8
  import type { ModelRegistry } from "../model-registry";
9
9
  import type { SessionManager } from "../session-manager";
@@ -400,7 +400,7 @@ export class HookRunner {
400
400
  */
401
401
  async emitBeforeAgentStart(
402
402
  prompt: string,
403
- images?: import("@mariozechner/pi-ai").ImageContent[],
403
+ images?: import("@oh-my-pi/pi-ai").ImageContent[],
404
404
  ): Promise<BeforeAgentStartEventResult | undefined> {
405
405
  const ctx = this.createContext();
406
406
  let result: BeforeAgentStartEventResult | undefined;
@@ -5,8 +5,8 @@
5
5
  * and interact with the user via UI primitives.
6
6
  */
7
7
 
8
- import type { ImageContent, Message, Model, TextContent, ToolResultMessage } from "@mariozechner/pi-ai";
9
8
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
9
+ import type { ImageContent, Message, Model, TextContent, ToolResultMessage } from "@oh-my-pi/pi-ai";
10
10
  import type { Component, TUI } from "@oh-my-pi/pi-tui";
11
11
  import type { Theme } from "../../modes/interactive/theme/theme";
12
12
  import type { CompactionPreparation, CompactionResult } from "../compaction/index";
package/src/core/index.ts CHANGED
@@ -38,5 +38,16 @@ export {
38
38
  type MCPToolsLoadResult,
39
39
  type MCPTransport,
40
40
  } from "./mcp/index";
41
+ export {
42
+ buildRemoteCommand,
43
+ closeAllConnections,
44
+ closeConnection,
45
+ ensureConnection,
46
+ getControlDir,
47
+ getControlPathTemplate,
48
+ type SSHConnectionTarget,
49
+ } from "./ssh/connection-manager";
50
+ export { executeSSH, type SSHExecutorOptions, type SSHResult } from "./ssh/ssh-executor";
51
+ export { hasSshfs, isMounted, mountRemote, unmountAll, unmountRemote } from "./ssh/sshfs-mount";
41
52
 
42
53
  export * as utils from "./utils";
@@ -5,8 +5,8 @@
5
5
  * and provides a transformer to convert them to LLM-compatible messages.
6
6
  */
7
7
 
8
- import type { ImageContent, Message, TextContent } from "@mariozechner/pi-ai";
9
8
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
9
+ import type { ImageContent, Message, TextContent } from "@oh-my-pi/pi-ai";
10
10
 
11
11
  export const COMPACTION_SUMMARY_PREFIX = `The conversation history before this point was compacted into the following summary:
12
12
 
@@ -11,7 +11,7 @@ import {
11
11
  type KnownProvider,
12
12
  type Model,
13
13
  normalizeDomain,
14
- } from "@mariozechner/pi-ai";
14
+ } from "@oh-my-pi/pi-ai";
15
15
  import { type Static, Type } from "@sinclair/typebox";
16
16
  import AjvModule from "ajv";
17
17
  import type { AuthStorage } from "./auth-storage";
@@ -2,8 +2,8 @@
2
2
  * Model resolution, scoping, and initial selection
3
3
  */
4
4
 
5
- import { type Api, type KnownProvider, type Model, modelsAreEqual } from "@mariozechner/pi-ai";
6
5
  import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
6
+ import { type Api, type KnownProvider, type Model, modelsAreEqual } from "@oh-my-pi/pi-ai";
7
7
  import chalk from "chalk";
8
8
  import { minimatch } from "minimatch";
9
9
  import { isValidThinkingLevel } from "../cli/args";
@@ -25,6 +25,7 @@ export const defaultModelPerProvider: Record<KnownProvider, string> = {
25
25
  cerebras: "zai-glm-4.6",
26
26
  zai: "glm-4.6",
27
27
  mistral: "devstral-medium-latest",
28
+ opencode: "claude-sonnet-4-5",
28
29
  };
29
30
 
30
31
  export interface ScopedModel {
@@ -33,7 +34,7 @@ export interface ScopedModel {
33
34
  }
34
35
 
35
36
  /** Priority chain for auto-discovering smol/fast models */
36
- export const SMOL_MODEL_PRIORITY = ["claude-haiku-4-5", "haiku", "flash", "mini"];
37
+ export const SMOL_MODEL_PRIORITY = ["cerebras/zai-glm-4.6", "claude-haiku-4-5", "haiku", "flash", "mini"];
37
38
 
38
39
  /** Priority chain for auto-discovering slow/comprehensive models (reasoning, codex) */
39
40
  export const SLOW_MODEL_PRIORITY = ["gpt-5.2-codex", "gpt-5.2", "codex", "gpt", "opus", "pro"];
@@ -443,12 +444,16 @@ export async function findSmolModel(
443
444
 
444
445
  // 2. Try priority chain
445
446
  for (const pattern of SMOL_MODEL_PRIORITY) {
447
+ // Try exact match with provider prefix
448
+ const providerMatch = availableModels.find((m) => `${m.provider}/${m.id}`.toLowerCase() === pattern);
449
+ if (providerMatch) return providerMatch;
450
+
446
451
  // Try exact match first
447
- const exactMatch = availableModels.find((m) => m.id.toLowerCase() === pattern.toLowerCase());
452
+ const exactMatch = availableModels.find((m) => m.id.toLowerCase() === pattern);
448
453
  if (exactMatch) return exactMatch;
449
454
 
450
455
  // Try fuzzy match (substring)
451
- const fuzzyMatch = availableModels.find((m) => m.id.toLowerCase().includes(pattern.toLowerCase()));
456
+ const fuzzyMatch = availableModels.find((m) => m.id.toLowerCase().includes(pattern));
452
457
  if (fuzzyMatch) return fuzzyMatch;
453
458
  }
454
459
 
package/src/core/sdk.ts CHANGED
@@ -27,8 +27,8 @@
27
27
  */
28
28
 
29
29
  import { join } from "node:path";
30
- import type { Model } from "@mariozechner/pi-ai";
31
30
  import { Agent, type AgentTool, type ThinkingLevel } from "@oh-my-pi/pi-agent-core";
31
+ import type { Model } from "@oh-my-pi/pi-ai";
32
32
  import type { Component } from "@oh-my-pi/pi-tui";
33
33
  import chalk from "chalk";
34
34
  // Import discovery to register all providers on startup
@@ -37,6 +37,7 @@ import { loadSync as loadCapability } from "../capability/index";
37
37
  import { type Rule, ruleCapability } from "../capability/rule";
38
38
  import { getAgentDir, getConfigDirPaths } from "../config";
39
39
  import { initializeWithSettings } from "../discovery";
40
+ import { registerAsyncCleanup } from "../modes/cleanup";
40
41
  import { AgentSession } from "./agent-session";
41
42
  import { AuthStorage } from "./auth-storage";
42
43
  import {
@@ -67,6 +68,8 @@ import { SessionManager } from "./session-manager";
67
68
  import { type Settings, SettingsManager, type SkillsSettings } from "./settings-manager";
68
69
  import { loadSkills as loadSkillsInternal, type Skill } from "./skills";
69
70
  import { type FileSlashCommand, loadSlashCommands as loadSlashCommandsInternal } from "./slash-commands";
71
+ import { closeAllConnections } from "./ssh/connection-manager";
72
+ import { unmountAll } from "./ssh/sshfs-mount";
70
73
  import {
71
74
  buildSystemPrompt as buildSystemPromptInternal,
72
75
  loadProjectContextFiles as loadContextFilesInternal,
@@ -83,6 +86,7 @@ import {
83
86
  createGrepTool,
84
87
  createLsTool,
85
88
  createReadTool,
89
+ createSshTool,
86
90
  createTools,
87
91
  createWriteTool,
88
92
  filterRulebookRules,
@@ -204,6 +208,7 @@ export {
204
208
  // Individual tool factories (for custom usage)
205
209
  createReadTool,
206
210
  createBashTool,
211
+ createSshTool,
207
212
  createEditTool,
208
213
  createWriteTool,
209
214
  createGrepTool,
@@ -399,6 +404,23 @@ function isCustomTool(tool: CustomTool | ToolDefinition): tool is CustomTool {
399
404
 
400
405
  const TOOL_DEFINITION_MARKER = Symbol("__isToolDefinition");
401
406
 
407
+ let sshCleanupRegistered = false;
408
+
409
+ async function cleanupSshResources(): Promise<void> {
410
+ const results = await Promise.allSettled([closeAllConnections(), unmountAll()]);
411
+ for (const result of results) {
412
+ if (result.status === "rejected") {
413
+ logger.warn("SSH cleanup failed", { error: String(result.reason) });
414
+ }
415
+ }
416
+ }
417
+
418
+ function registerSshCleanup(): void {
419
+ if (sshCleanupRegistered) return;
420
+ sshCleanupRegistered = true;
421
+ registerAsyncCleanup(() => cleanupSshResources());
422
+ }
423
+
402
424
  function customToolToDefinition(tool: CustomTool): ToolDefinition {
403
425
  const definition: ToolDefinition & { [TOOL_DEFINITION_MARKER]: true } = {
404
426
  name: tool.name,
@@ -471,7 +493,7 @@ function createCustomToolsExtension(tools: CustomTool[]): ExtensionFactory {
471
493
  * const { session } = await createAgentSession();
472
494
  *
473
495
  * // With explicit model
474
- * import { getModel } from '@mariozechner/pi-ai';
496
+ * import { getModel } from '@oh-my-pi/pi-ai';
475
497
  * const { session } = await createAgentSession({
476
498
  * model: getModel('anthropic', 'claude-opus-4-5'),
477
499
  * thinkingLevel: 'high',
@@ -498,6 +520,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
498
520
  const agentDir = options.agentDir ?? getDefaultAgentDir();
499
521
  const eventBus = options.eventBus ?? createEventBus();
500
522
 
523
+ registerSshCleanup();
524
+
501
525
  // Use provided or create AuthStorage and ModelRegistry
502
526
  const authStorage = options.authStorage ?? (await discoverAuthStorage(agentDir));
503
527
  const modelRegistry = options.modelRegistry ?? (await discoverModels(authStorage, agentDir));
@@ -1,6 +1,6 @@
1
1
  import { basename, join, resolve } from "node:path";
2
- import type { ImageContent, Message, TextContent, Usage } from "@mariozechner/pi-ai";
3
2
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
3
+ import type { ImageContent, Message, TextContent, Usage } from "@oh-my-pi/pi-ai";
4
4
  import { nanoid } from "nanoid";
5
5
  import { getAgentDir as getDefaultAgentDir } from "../config";
6
6
  import { resizeImage } from "../utils/image-resize";
@@ -11,6 +11,7 @@ import {
11
11
  createBranchSummaryMessage,
12
12
  createCompactionSummaryMessage,
13
13
  createCustomMessage,
14
+ type FileMentionMessage,
14
15
  type HookMessage,
15
16
  } from "./messages";
16
17
  import type { SessionStorage, SessionStorageWriter } from "./session-storage";
@@ -1179,7 +1180,7 @@ export class SessionManager {
1179
1180
  * so it is easier to find them.
1180
1181
  * These need to be appended via appendCompaction() and appendBranchSummary() methods.
1181
1182
  */
1182
- appendMessage(message: Message | CustomMessage | HookMessage | BashExecutionMessage): string {
1183
+ appendMessage(message: Message | CustomMessage | HookMessage | BashExecutionMessage | FileMentionMessage): string {
1183
1184
  const entry: SessionMessageEntry = {
1184
1185
  type: "message",
1185
1186
  id: generateId(this.byId),
@@ -179,6 +179,8 @@ export interface Settings {
179
179
  shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows)
180
180
  collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full)
181
181
  doubleEscapeAction?: "branch" | "tree"; // Action for double-escape with empty editor (default: "tree")
182
+ /** Environment variables to set automatically on startup */
183
+ env?: Record<string, string>;
182
184
  extensions?: string[]; // Array of extension file paths
183
185
  skills?: SkillsSettings;
184
186
  commands?: CommandsSettings;
@@ -379,6 +381,29 @@ export class SettingsManager {
379
381
  this.globalSettings = initialSettings;
380
382
  const projectSettings = this.loadProjectSettings();
381
383
  this.settings = normalizeSettings(deepMergeSettings(this.globalSettings, projectSettings));
384
+
385
+ // Apply environment variables from settings
386
+ this.applyEnvironmentVariables();
387
+ }
388
+
389
+ /**
390
+ * Apply environment variables from settings to process.env
391
+ * Only sets variables that are not already set in the environment
392
+ */
393
+ applyEnvironmentVariables(): void {
394
+ const envVars = this.settings.env;
395
+ if (!envVars || typeof envVars !== "object") {
396
+ return;
397
+ }
398
+
399
+ for (const [key, value] of Object.entries(envVars)) {
400
+ if (typeof key === "string" && typeof value === "string") {
401
+ // Only set if not already present in environment (allow override with env vars)
402
+ if (!(key in process.env)) {
403
+ process.env[key] = value;
404
+ }
405
+ }
406
+ }
382
407
  }
383
408
 
384
409
  /** Create a SettingsManager that loads from files */
@@ -1169,4 +1194,49 @@ export class SettingsManager {
1169
1194
  this.globalSettings.doubleEscapeAction = action;
1170
1195
  this.save();
1171
1196
  }
1197
+
1198
+ /**
1199
+ * Get environment variables from settings
1200
+ */
1201
+ getEnvironmentVariables(): Record<string, string> {
1202
+ return { ...(this.settings.env ?? {}) };
1203
+ }
1204
+
1205
+ /**
1206
+ * Set environment variables in settings (not process.env)
1207
+ * This will be applied on next startup or reload
1208
+ */
1209
+ setEnvironmentVariables(envVars: Record<string, string>): void {
1210
+ this.globalSettings.env = { ...envVars };
1211
+ this.save();
1212
+ }
1213
+
1214
+ /**
1215
+ * Clear all environment variables from settings
1216
+ */
1217
+ clearEnvironmentVariables(): void {
1218
+ delete this.globalSettings.env;
1219
+ this.save();
1220
+ }
1221
+
1222
+ /**
1223
+ * Set a single environment variable in settings
1224
+ */
1225
+ setEnvironmentVariable(key: string, value: string): void {
1226
+ if (!this.globalSettings.env) {
1227
+ this.globalSettings.env = {};
1228
+ }
1229
+ this.globalSettings.env[key] = value;
1230
+ this.save();
1231
+ }
1232
+
1233
+ /**
1234
+ * Remove a single environment variable from settings
1235
+ */
1236
+ removeEnvironmentVariable(key: string): void {
1237
+ if (this.globalSettings.env) {
1238
+ delete this.globalSettings.env[key];
1239
+ this.save();
1240
+ }
1241
+ }
1172
1242
  }