@oh-my-pi/pi-coding-agent 12.8.1 → 12.9.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,33 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [12.9.0] - 2026-02-17
6
+
7
+ ### Added
8
+
9
+ - Added OpenCode discovery provider to load configuration from ~/.config/opencode/ and .opencode/ directories
10
+ - Added support for loading MCP servers from opencode.json mcp key
11
+ - Added support for loading skills from ~/.config/opencode/skills/ and .opencode/skills/
12
+ - Added support for loading slash commands from ~/.config/opencode/commands/ and .opencode/commands/
13
+ - Added support for loading extension modules (plugins) from ~/.config/opencode/plugins/ and .opencode/plugins/
14
+ - Added support for loading context files (AGENTS.md) from ~/.config/opencode/
15
+ - Added support for loading settings from opencode.json configuration files
16
+
17
+ ### Changed
18
+
19
+ - Improved path display in status line to strip both `/work/` and `~/Projects/` prefixes when abbreviating paths
20
+ - Refactored session directory naming to use single-dash format for home-relative paths and double-dash format for absolute paths, with automatic migration of legacy session directories on first access
21
+
22
+ ## [12.8.2] - 2026-02-17
23
+ ### Changed
24
+
25
+ - Changed system environment context to use built-in `os` values for distro, kernel, and CPU model instead of native system-info data
26
+ - Changed environment info generation to stop including unavailable native system detail fallbacks
27
+
28
+ ### Removed
29
+
30
+ - Removed the `Disk` field from generated environment information
31
+
5
32
  ## [12.8.0] - 2026-02-16
6
33
 
7
34
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "12.8.1",
3
+ "version": "12.9.0",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "bin": {
@@ -84,12 +84,12 @@
84
84
  },
85
85
  "dependencies": {
86
86
  "@mozilla/readability": "0.6.0",
87
- "@oh-my-pi/omp-stats": "12.8.1",
88
- "@oh-my-pi/pi-agent-core": "12.8.1",
89
- "@oh-my-pi/pi-ai": "12.8.1",
90
- "@oh-my-pi/pi-natives": "12.8.1",
91
- "@oh-my-pi/pi-tui": "12.8.1",
92
- "@oh-my-pi/pi-utils": "12.8.1",
87
+ "@oh-my-pi/omp-stats": "12.9.0",
88
+ "@oh-my-pi/pi-agent-core": "12.9.0",
89
+ "@oh-my-pi/pi-ai": "12.9.0",
90
+ "@oh-my-pi/pi-natives": "12.9.0",
91
+ "@oh-my-pi/pi-tui": "12.9.0",
92
+ "@oh-my-pi/pi-utils": "12.9.0",
93
93
  "@sinclair/typebox": "^0.34.48",
94
94
  "@xterm/headless": "^6.0.0",
95
95
  "ajv": "^8.18.0",
@@ -60,6 +60,11 @@ export const SOURCE_PATHS = {
60
60
  userAgent: ".gemini",
61
61
  projectDir: ".gemini",
62
62
  },
63
+ opencode: {
64
+ userBase: ".config/opencode",
65
+ userAgent: ".config/opencode",
66
+ projectDir: ".opencode",
67
+ },
63
68
  cursor: {
64
69
  userBase: ".cursor",
65
70
  userAgent: ".cursor",
@@ -29,6 +29,7 @@ import "./agents";
29
29
  import "./codex";
30
30
  import "./cursor";
31
31
  import "./gemini";
32
+ import "./opencode";
32
33
  import "./github";
33
34
  import "./mcp-json";
34
35
  import "./ssh";
@@ -0,0 +1,394 @@
1
+ /**
2
+ * OpenCode Discovery Provider
3
+ *
4
+ * Loads configuration from OpenCode's config directories:
5
+ * - User: ~/.config/opencode/
6
+ * - Project: .opencode/ (cwd) and opencode.json (project root)
7
+ *
8
+ * Capabilities:
9
+ * - context-files: AGENTS.md (user-level only at ~/.config/opencode/AGENTS.md)
10
+ * - mcps: From opencode.json "mcp" key
11
+ * - settings: From opencode.json
12
+ * - skills: From skills/ subdirectories
13
+ * - slash-commands: From commands/ subdirectories
14
+ * - extension-modules: From plugins/ subdirectories
15
+ *
16
+ * Priority: 55 (tool-specific provider)
17
+ */
18
+ import * as path from "node:path";
19
+ import { logger } from "@oh-my-pi/pi-utils";
20
+ import { registerProvider } from "../capability";
21
+ import { type ContextFile, contextFileCapability } from "../capability/context-file";
22
+ import { type ExtensionModule, extensionModuleCapability } from "../capability/extension-module";
23
+ import { readFile } from "../capability/fs";
24
+ import { type MCPServer, mcpCapability } from "../capability/mcp";
25
+ import { type Settings, settingsCapability } from "../capability/settings";
26
+ import { type Skill, skillCapability } from "../capability/skill";
27
+ import { type SlashCommand, slashCommandCapability } from "../capability/slash-command";
28
+ import type { LoadContext, LoadResult, SourceMeta } from "../capability/types";
29
+ import { parseFrontmatter } from "../utils/frontmatter";
30
+ import {
31
+ createSourceMeta,
32
+ discoverExtensionModulePaths,
33
+ expandEnvVarsDeep,
34
+ getExtensionNameFromPath,
35
+ getProjectPath,
36
+ getUserPath,
37
+ loadFilesFromDir,
38
+ loadSkillsFromDir,
39
+ parseJSON,
40
+ } from "./helpers";
41
+
42
+ const PROVIDER_ID = "opencode";
43
+ const DISPLAY_NAME = "OpenCode";
44
+ const PRIORITY = 55;
45
+
46
+ // =============================================================================
47
+ // JSON Config Loading
48
+ // =============================================================================
49
+
50
+ async function loadJsonConfig(configPath: string): Promise<Record<string, unknown> | null> {
51
+ const content = await readFile(configPath);
52
+ if (!content) return null;
53
+
54
+ const parsed = parseJSON<Record<string, unknown>>(content);
55
+ if (!parsed) {
56
+ logger.warn("Failed to parse OpenCode JSON config", { path: configPath });
57
+ return null;
58
+ }
59
+ return parsed;
60
+ }
61
+
62
+ // =============================================================================
63
+ // Context Files (AGENTS.md)
64
+ // =============================================================================
65
+
66
+ async function loadContextFiles(ctx: LoadContext): Promise<LoadResult<ContextFile>> {
67
+ const items: ContextFile[] = [];
68
+ const warnings: string[] = [];
69
+
70
+ // User-level only: ~/.config/opencode/AGENTS.md
71
+ const userAgentsMd = getUserPath(ctx, "opencode", "AGENTS.md");
72
+ if (userAgentsMd) {
73
+ const content = await readFile(userAgentsMd);
74
+ if (content) {
75
+ items.push({
76
+ path: userAgentsMd,
77
+ content,
78
+ level: "user",
79
+ _source: createSourceMeta(PROVIDER_ID, userAgentsMd, "user"),
80
+ });
81
+ }
82
+ }
83
+
84
+ return { items, warnings };
85
+ }
86
+
87
+ // =============================================================================
88
+ // MCP Servers (opencode.json → mcp)
89
+ // =============================================================================
90
+
91
+ /** OpenCode MCP server config (from opencode.json "mcp" key) */
92
+ interface OpenCodeMCPConfig {
93
+ type?: "local" | "remote";
94
+ command?: string;
95
+ args?: string[];
96
+ env?: Record<string, string>;
97
+ url?: string;
98
+ headers?: Record<string, string>;
99
+ enabled?: boolean;
100
+ timeout?: number;
101
+ }
102
+
103
+ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>> {
104
+ const items: MCPServer[] = [];
105
+ const warnings: string[] = [];
106
+
107
+ // User-level: ~/.config/opencode/opencode.json
108
+ const userConfigPath = getUserPath(ctx, "opencode", "opencode.json");
109
+ if (userConfigPath) {
110
+ const config = await loadJsonConfig(userConfigPath);
111
+ if (config) {
112
+ const result = extractMCPServers(config, userConfigPath, "user");
113
+ items.push(...result.items);
114
+ if (result.warnings) warnings.push(...result.warnings);
115
+ }
116
+ }
117
+
118
+ // Project-level: opencode.json in project root
119
+ const projectConfigPath = path.join(ctx.cwd, "opencode.json");
120
+ const projectConfig = await loadJsonConfig(projectConfigPath);
121
+ if (projectConfig) {
122
+ const result = extractMCPServers(projectConfig, projectConfigPath, "project");
123
+ items.push(...result.items);
124
+ if (result.warnings) warnings.push(...result.warnings);
125
+ }
126
+
127
+ return { items, warnings };
128
+ }
129
+
130
+ function extractMCPServers(
131
+ config: Record<string, unknown>,
132
+ configPath: string,
133
+ level: "user" | "project",
134
+ ): LoadResult<MCPServer> {
135
+ const items: MCPServer[] = [];
136
+ const warnings: string[] = [];
137
+
138
+ if (!config.mcp || typeof config.mcp !== "object") {
139
+ return { items, warnings };
140
+ }
141
+
142
+ const servers = expandEnvVarsDeep(config.mcp as Record<string, unknown>);
143
+
144
+ for (const [name, raw] of Object.entries(servers)) {
145
+ if (!raw || typeof raw !== "object") {
146
+ warnings.push(`Invalid MCP config for "${name}" in ${configPath}`);
147
+ continue;
148
+ }
149
+
150
+ const serverConfig = raw as OpenCodeMCPConfig;
151
+
152
+ // Determine transport from OpenCode's "type" field
153
+ let transport: "stdio" | "sse" | "http" | undefined;
154
+ if (serverConfig.type === "local") {
155
+ transport = "stdio";
156
+ } else if (serverConfig.type === "remote") {
157
+ transport = "http";
158
+ } else if (serverConfig.url) {
159
+ transport = "http";
160
+ } else if (serverConfig.command) {
161
+ transport = "stdio";
162
+ }
163
+
164
+ items.push({
165
+ name,
166
+ command: serverConfig.command,
167
+ args: Array.isArray(serverConfig.args) ? (serverConfig.args as string[]) : undefined,
168
+ env: serverConfig.env && typeof serverConfig.env === "object" ? serverConfig.env : undefined,
169
+ url: typeof serverConfig.url === "string" ? serverConfig.url : undefined,
170
+ headers: serverConfig.headers && typeof serverConfig.headers === "object" ? serverConfig.headers : undefined,
171
+ enabled: serverConfig.enabled,
172
+ timeout: typeof serverConfig.timeout === "number" ? serverConfig.timeout : undefined,
173
+ transport,
174
+ _source: createSourceMeta(PROVIDER_ID, configPath, level),
175
+ });
176
+ }
177
+
178
+ return { items, warnings };
179
+ }
180
+
181
+ // =============================================================================
182
+ // Skills (skills/)
183
+ // =============================================================================
184
+
185
+ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
186
+ const userSkillsDir = getUserPath(ctx, "opencode", "skills");
187
+ const projectSkillsDir = getProjectPath(ctx, "opencode", "skills");
188
+
189
+ const promises: Promise<LoadResult<Skill>>[] = [];
190
+
191
+ if (userSkillsDir) {
192
+ promises.push(
193
+ loadSkillsFromDir(ctx, {
194
+ dir: userSkillsDir,
195
+ providerId: PROVIDER_ID,
196
+ level: "user",
197
+ }),
198
+ );
199
+ }
200
+
201
+ if (projectSkillsDir) {
202
+ promises.push(
203
+ loadSkillsFromDir(ctx, {
204
+ dir: projectSkillsDir,
205
+ providerId: PROVIDER_ID,
206
+ level: "project",
207
+ }),
208
+ );
209
+ }
210
+
211
+ const results = await Promise.all(promises);
212
+ const items = results.flatMap(r => r.items);
213
+ const warnings = results.flatMap(r => r.warnings || []);
214
+
215
+ return { items, warnings };
216
+ }
217
+
218
+ // =============================================================================
219
+ // Extension Modules (plugins/)
220
+ // =============================================================================
221
+
222
+ async function loadExtensionModules(ctx: LoadContext): Promise<LoadResult<ExtensionModule>> {
223
+ const userPluginsDir = getUserPath(ctx, "opencode", "plugins");
224
+ const projectPluginsDir = getProjectPath(ctx, "opencode", "plugins");
225
+
226
+ const [userPaths, projectPaths] = await Promise.all([
227
+ userPluginsDir ? discoverExtensionModulePaths(ctx, userPluginsDir) : Promise.resolve([]),
228
+ projectPluginsDir ? discoverExtensionModulePaths(ctx, projectPluginsDir) : Promise.resolve([]),
229
+ ]);
230
+
231
+ const items: ExtensionModule[] = [
232
+ ...userPaths.map(extPath => ({
233
+ name: getExtensionNameFromPath(extPath),
234
+ path: extPath,
235
+ level: "user" as const,
236
+ _source: createSourceMeta(PROVIDER_ID, extPath, "user"),
237
+ })),
238
+ ...projectPaths.map(extPath => ({
239
+ name: getExtensionNameFromPath(extPath),
240
+ path: extPath,
241
+ level: "project" as const,
242
+ _source: createSourceMeta(PROVIDER_ID, extPath, "project"),
243
+ })),
244
+ ];
245
+
246
+ return { items, warnings: [] };
247
+ }
248
+
249
+ // =============================================================================
250
+ // Slash Commands (commands/)
251
+ // =============================================================================
252
+
253
+ async function loadSlashCommands(ctx: LoadContext): Promise<LoadResult<SlashCommand>> {
254
+ const userCommandsDir = getUserPath(ctx, "opencode", "commands");
255
+ const projectCommandsDir = getProjectPath(ctx, "opencode", "commands");
256
+
257
+ const transformCommand =
258
+ (level: "user" | "project") => (name: string, content: string, filePath: string, source: SourceMeta) => {
259
+ const { frontmatter, body } = parseFrontmatter(content, { source: filePath });
260
+ const commandName = frontmatter.name || name.replace(/\.md$/, "");
261
+ return {
262
+ name: String(commandName),
263
+ path: filePath,
264
+ content: body,
265
+ level,
266
+ _source: source,
267
+ };
268
+ };
269
+
270
+ const promises: Promise<LoadResult<SlashCommand>>[] = [];
271
+
272
+ if (userCommandsDir) {
273
+ promises.push(
274
+ loadFilesFromDir(ctx, userCommandsDir, PROVIDER_ID, "user", {
275
+ extensions: ["md"],
276
+ transform: transformCommand("user"),
277
+ }),
278
+ );
279
+ }
280
+
281
+ if (projectCommandsDir) {
282
+ promises.push(
283
+ loadFilesFromDir(ctx, projectCommandsDir, PROVIDER_ID, "project", {
284
+ extensions: ["md"],
285
+ transform: transformCommand("project"),
286
+ }),
287
+ );
288
+ }
289
+
290
+ const results = await Promise.all(promises);
291
+ const items = results.flatMap(r => r.items);
292
+ const warnings = results.flatMap(r => r.warnings || []);
293
+
294
+ return { items, warnings };
295
+ }
296
+
297
+ // =============================================================================
298
+ // Settings (opencode.json)
299
+ // =============================================================================
300
+
301
+ async function loadSettings(ctx: LoadContext): Promise<LoadResult<Settings>> {
302
+ const items: Settings[] = [];
303
+ const warnings: string[] = [];
304
+
305
+ // User-level: ~/.config/opencode/opencode.json
306
+ const userConfigPath = getUserPath(ctx, "opencode", "opencode.json");
307
+ if (userConfigPath) {
308
+ const content = await readFile(userConfigPath);
309
+ if (content) {
310
+ const parsed = parseJSON<Record<string, unknown>>(content);
311
+ if (parsed) {
312
+ items.push({
313
+ path: userConfigPath,
314
+ data: parsed,
315
+ level: "user",
316
+ _source: createSourceMeta(PROVIDER_ID, userConfigPath, "user"),
317
+ });
318
+ } else {
319
+ warnings.push(`Invalid JSON in ${userConfigPath}`);
320
+ }
321
+ }
322
+ }
323
+
324
+ // Project-level: opencode.json in project root
325
+ const projectConfigPath = path.join(ctx.cwd, "opencode.json");
326
+ const content = await readFile(projectConfigPath);
327
+ if (content) {
328
+ const parsed = parseJSON<Record<string, unknown>>(content);
329
+ if (parsed) {
330
+ items.push({
331
+ path: projectConfigPath,
332
+ data: parsed,
333
+ level: "project",
334
+ _source: createSourceMeta(PROVIDER_ID, projectConfigPath, "project"),
335
+ });
336
+ } else {
337
+ warnings.push(`Invalid JSON in ${projectConfigPath}`);
338
+ }
339
+ }
340
+
341
+ return { items, warnings };
342
+ }
343
+
344
+ // =============================================================================
345
+ // Provider Registration
346
+ // =============================================================================
347
+
348
+ registerProvider(contextFileCapability.id, {
349
+ id: PROVIDER_ID,
350
+ displayName: DISPLAY_NAME,
351
+ description: "Load AGENTS.md from ~/.config/opencode/",
352
+ priority: PRIORITY,
353
+ load: loadContextFiles,
354
+ });
355
+
356
+ registerProvider(mcpCapability.id, {
357
+ id: PROVIDER_ID,
358
+ displayName: DISPLAY_NAME,
359
+ description: "Load MCP servers from opencode.json mcp key",
360
+ priority: PRIORITY,
361
+ load: loadMCPServers,
362
+ });
363
+
364
+ registerProvider(skillCapability.id, {
365
+ id: PROVIDER_ID,
366
+ displayName: DISPLAY_NAME,
367
+ description: "Load skills from ~/.config/opencode/skills/ and .opencode/skills/",
368
+ priority: PRIORITY,
369
+ load: loadSkills,
370
+ });
371
+
372
+ registerProvider(extensionModuleCapability.id, {
373
+ id: PROVIDER_ID,
374
+ displayName: DISPLAY_NAME,
375
+ description: "Load extension modules from ~/.config/opencode/plugins/ and .opencode/plugins/",
376
+ priority: PRIORITY,
377
+ load: loadExtensionModules,
378
+ });
379
+
380
+ registerProvider(slashCommandCapability.id, {
381
+ id: PROVIDER_ID,
382
+ displayName: DISPLAY_NAME,
383
+ description: "Load slash commands from ~/.config/opencode/commands/ and .opencode/commands/",
384
+ priority: PRIORITY,
385
+ load: loadSlashCommands,
386
+ });
387
+
388
+ registerProvider(settingsCapability.id, {
389
+ id: PROVIDER_ID,
390
+ displayName: DISPLAY_NAME,
391
+ description: "Load settings from opencode.json",
392
+ priority: PRIORITY,
393
+ load: loadSettings,
394
+ });
@@ -5,7 +5,7 @@ import type { AgentTool, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core
5
5
  import type { Static, TSchema } from "@sinclair/typebox";
6
6
  import type { Theme } from "../../modes/theme/theme";
7
7
  import { applyToolProxy } from "../tool-proxy";
8
- import type { CustomTool, CustomToolContext, LoadedCustomTool } from "./types";
8
+ import type { CustomTool, CustomToolContext } from "./types";
9
9
 
10
10
  export class CustomToolAdapter<TParams extends TSchema = TSchema, TDetails = any, TTheme extends Theme = Theme>
11
11
  implements AgentTool<TParams, TDetails, TTheme>
@@ -42,14 +42,4 @@ export class CustomToolAdapter<TParams extends TSchema = TSchema, TDetails = any
42
42
  ): AgentTool<TParams, TDetails, TTheme> {
43
43
  return new CustomToolAdapter(tool, getContext);
44
44
  }
45
-
46
- /**
47
- * Wrap all loaded custom tools into AgentTools.
48
- */
49
- static wrapTools<TParams extends TSchema = TSchema, TDetails = any, TTheme extends Theme = Theme>(
50
- loadedTools: LoadedCustomTool<TParams, TDetails>[],
51
- getContext: () => CustomToolContext,
52
- ): AgentTool<TParams, TDetails, TTheme>[] {
53
- return loadedTools.map(lt => CustomToolAdapter.wrap(lt.tool, getContext));
54
- }
55
45
  }
@@ -97,8 +97,9 @@ const pathSegment: StatusLineSegment = {
97
97
  if (opts.abbreviate !== false) {
98
98
  pwd = shortenPath(pwd);
99
99
  }
100
- if (opts.stripWorkPrefix !== false && pwd.startsWith("/work/")) {
101
- pwd = pwd.slice(6);
100
+ if (opts.stripWorkPrefix !== false) {
101
+ if (pwd.startsWith("/work/")) pwd = pwd.slice(6);
102
+ else if (pwd.startsWith("~/Projects/")) pwd = pwd.slice(11);
102
103
  }
103
104
 
104
105
  const maxLen = opts.maxLength ?? 40;
package/src/sdk.ts CHANGED
@@ -79,7 +79,6 @@ import {
79
79
  } from "./tools";
80
80
  import { ToolContextStore } from "./tools/context";
81
81
  import { getGeminiImageTools } from "./tools/gemini-image";
82
- import { wrapToolsWithMetaNotice } from "./tools/output-meta";
83
82
  import { EventBus } from "./utils/event-bus";
84
83
  import { time } from "./utils/timings";
85
84
 
@@ -762,9 +761,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
762
761
  );
763
762
 
764
763
  debugStartup("sdk:createTools:start");
765
- // Create and wrap tools with meta notice formatting
766
- const rawBuiltinTools = await createTools(toolSession, options.toolNames);
767
- const builtinTools = wrapToolsWithMetaNotice(rawBuiltinTools);
764
+ // Create built-in tools (already wrapped with meta notice formatting)
765
+ const builtinTools = await createTools(toolSession, options.toolNames);
768
766
  debugStartup("sdk:createTools");
769
767
  time("createAllTools");
770
768
 
@@ -1,7 +1,9 @@
1
1
  import * as fs from "node:fs";
2
+ import * as os from "node:os";
2
3
  import * as path from "node:path";
3
4
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
4
5
  import type { ImageContent, Message, TextContent, Usage } from "@oh-my-pi/pi-ai";
6
+ import { getTerminalId } from "@oh-my-pi/pi-tui";
5
7
  import { isEnoent, logger, parseJsonlLenient, Snowflake } from "@oh-my-pi/pi-utils";
6
8
  import { getBlobsDir, getAgentDir as getDefaultAgentDir, getProjectDir } from "@oh-my-pi/pi-utils/dirs";
7
9
  import { type BlobPutResult, BlobStore, externalizeImageData, isBlobRef, resolveImageData } from "./blob-store";
@@ -302,6 +304,67 @@ export function migrateSessionEntries(entries: FileEntry[]): void {
302
304
  migrateToCurrentVersion(entries);
303
305
  }
304
306
 
307
+ let sessionDirsMigrated = false;
308
+
309
+ /**
310
+ * Migrate old `--<home-encoded>-*--` session dirs to the new `-*` format.
311
+ * Runs once on first access, best-effort.
312
+ */
313
+ function migrateHomeSessionDirs(): void {
314
+ if (sessionDirsMigrated) return;
315
+ sessionDirsMigrated = true;
316
+
317
+ const home = os.homedir();
318
+ const homeEncoded = home.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-");
319
+ const oldPrefix = `--${homeEncoded}-`;
320
+ const oldExact = `--${homeEncoded}--`;
321
+ const sessionsRoot = path.join(getDefaultAgentDir(), "sessions");
322
+
323
+ let entries: string[];
324
+ try {
325
+ entries = fs.readdirSync(sessionsRoot);
326
+ } catch {
327
+ return;
328
+ }
329
+
330
+ for (const entry of entries) {
331
+ let remainder: string;
332
+ if (entry === oldExact) {
333
+ remainder = "";
334
+ } else if (entry.startsWith(oldPrefix) && entry.endsWith("--")) {
335
+ remainder = entry.slice(oldPrefix.length, -2);
336
+ } else {
337
+ continue;
338
+ }
339
+
340
+ const newName = `-${remainder}`;
341
+ const oldPath = path.join(sessionsRoot, entry);
342
+ const newPath = path.join(sessionsRoot, newName);
343
+
344
+ try {
345
+ const existing = fs.statSync(newPath, { throwIfNoEntry: false });
346
+ if (existing?.isDirectory()) {
347
+ // Merge files from old dir into existing new dir
348
+ for (const file of fs.readdirSync(oldPath)) {
349
+ const src = path.join(oldPath, file);
350
+ const dst = path.join(newPath, file);
351
+ if (!fs.existsSync(dst)) {
352
+ fs.renameSync(src, dst);
353
+ }
354
+ }
355
+ fs.rmSync(oldPath, { recursive: true, force: true });
356
+ } else {
357
+ if (existing) {
358
+ fs.rmSync(newPath, { recursive: true, force: true });
359
+ }
360
+ fs.renameSync(oldPath, newPath);
361
+ }
362
+ } catch {
363
+ // Best effort
364
+ }
365
+ }
366
+ }
367
+
305
368
  /** Exported for compaction.test.ts */
306
369
  export function parseSessionEntries(content: string): FileEntry[] {
307
370
  return parseJsonlLenient<FileEntry>(content);
@@ -459,13 +522,27 @@ export function buildSessionContext(
459
522
  return { messages, thinkingLevel, models, injectedTtsrRules, mode, modeData };
460
523
  }
461
524
 
525
+ /**
526
+ * Encode a cwd into a safe directory name for session storage.
527
+ * Home-relative paths use single-dash format: `/Users/x/Projects/pi` → `-Projects-pi`
528
+ * Absolute paths use double-dash format: `/tmp/foo` → `--tmp-foo--`
529
+ */
530
+ function encodeSessionDirName(cwd: string): string {
531
+ const home = os.homedir();
532
+ if (cwd === home || cwd.startsWith(`${home}/`) || cwd.startsWith(`${home}\\`)) {
533
+ const relative = cwd.slice(home.length).replace(/^[/\\]/, "");
534
+ return `-${relative.replace(/[/\\:]/g, "-")}`;
535
+ }
536
+ return `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
537
+ }
462
538
  /**
463
539
  * Compute the default session directory for a cwd.
464
540
  * Encodes cwd into a safe directory name under ~/.omp/agent/sessions/.
465
541
  */
466
542
  function getDefaultSessionDir(cwd: string, storage: SessionStorage): string {
467
- const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
468
- const sessionDir = path.join(getDefaultAgentDir(), "sessions", safePath);
543
+ migrateHomeSessionDirs();
544
+ const dirName = encodeSessionDirName(cwd);
545
+ const sessionDir = path.join(getDefaultAgentDir(), "sessions", dirName);
469
546
  storage.ensureDirSync(sessionDir);
470
547
  return sessionDir;
471
548
  }
@@ -476,40 +553,6 @@ function getDefaultSessionDir(cwd: string, storage: SessionStorage): string {
476
553
 
477
554
  const TERMINAL_SESSIONS_DIR = "terminal-sessions";
478
555
 
479
- /**
480
- * Get a stable identifier for the current terminal.
481
- * Uses the TTY device path (e.g., /dev/pts/3), falling back to environment
482
- * variables for terminal multiplexers or terminal emulators.
483
- * Returns null if no terminal can be identified (e.g., piped input).
484
- */
485
- function getTerminalId(): string | null {
486
- // TTY device path — most reliable, unique per terminal tab
487
- if (process.stdin.isTTY) {
488
- try {
489
- // On Linux/macOS, /proc/self/fd/0 -> /dev/pts/N
490
- const ttyPath = fs.readlinkSync("/proc/self/fd/0");
491
- if (ttyPath.startsWith("/dev/")) {
492
- return ttyPath.slice(5).replace(/\//g, "-"); // /dev/pts/3 -> pts-3
493
- }
494
- } catch {}
495
- }
496
-
497
- // Fallback to terminal-specific env vars
498
- const kittyId = process.env.KITTY_WINDOW_ID;
499
- if (kittyId) return `kitty-${kittyId}`;
500
-
501
- const tmuxPane = process.env.TMUX_PANE;
502
- if (tmuxPane) return `tmux-${tmuxPane}`;
503
-
504
- const terminalSessionId = process.env.TERM_SESSION_ID; // macOS Terminal.app
505
- if (terminalSessionId) return `apple-${terminalSessionId}`;
506
-
507
- const wtSession = process.env.WT_SESSION; // Windows Terminal
508
- if (wtSession) return `wt-${wtSession}`;
509
-
510
- return null;
511
- }
512
-
513
556
  /**
514
557
  * Write a breadcrumb linking the current terminal to a session file.
515
558
  * The breadcrumb contains the cwd and session path so --continue can
@@ -2,7 +2,6 @@
2
2
  * System prompt construction and project context loading
3
3
  */
4
4
  import * as os from "node:os";
5
- import { getSystemInfo as getNativeSystemInfo, type SystemInfo } from "@oh-my-pi/pi-natives";
6
5
  import { $env, hasFsCode, isEnoent, logger } from "@oh-my-pi/pi-utils";
7
6
  import { getGpuCachePath, getProjectDir } from "@oh-my-pi/pi-utils/dirs";
8
7
  import { $ } from "bun";
@@ -310,35 +309,33 @@ async function saveGpuCache(info: GpuCache): Promise<void> {
310
309
  }
311
310
 
312
311
  async function getCachedGpu(): Promise<string | undefined> {
312
+ debugStartup("system-prompt:getEnvironmentInfo:getCachedGpu:start");
313
313
  const cached = await loadGpuCache();
314
314
  if (cached) return cached.gpu;
315
+ debugStartup("system-prompt:getEnvironmentInfo:getGpuModel");
315
316
  const gpu = await getGpuModel();
317
+ debugStartup("system-prompt:getEnvironmentInfo:saveGpuCache");
316
318
  if (gpu) await saveGpuCache({ gpu });
317
319
  return gpu ?? undefined;
318
320
  }
319
321
  async function getEnvironmentInfo(): Promise<Array<{ label: string; value: string }>> {
320
- let nativeInfo: SystemInfo | null = null;
321
- try {
322
- nativeInfo = getNativeSystemInfo();
323
- } catch {
324
- nativeInfo = null;
325
- }
326
-
322
+ debugStartup("system-prompt:getEnvironmentInfo:getCachedGpu");
327
323
  const gpu = await getCachedGpu();
324
+ debugStartup("system-prompt:getEnvironmentInfo:getCpuInfo");
328
325
  const cpus = os.cpus();
326
+ debugStartup("system-prompt:getEnvironmentInfo:buildEntries");
329
327
  const entries: Array<{ label: string; value: string | undefined }> = [
330
328
  { label: "OS", value: `${os.platform()} ${os.release()}` },
331
- { label: "Distro", value: nativeInfo?.distro ?? os.type() },
332
- { label: "Kernel", value: nativeInfo?.kernel ?? os.version() },
329
+ { label: "Distro", value: os.type() },
330
+ { label: "Kernel", value: os.version() },
333
331
  { label: "Arch", value: os.arch() },
334
- { label: "CPU", value: `${cpus.length}x ${nativeInfo?.cpu ?? cpus[0]?.model}` },
332
+ { label: "CPU", value: `${cpus.length}x ${cpus[0]?.model}` },
335
333
  { label: "GPU", value: gpu },
336
- { label: "Disk", value: nativeInfo?.disk ?? undefined },
337
334
  { label: "Terminal", value: getTerminalName() },
338
335
  { label: "DE", value: getDesktopEnvironment() },
339
336
  { label: "WM", value: getWindowManager() },
340
337
  ];
341
-
338
+ debugStartup("system-prompt:getEnvironmentInfo:done");
342
339
  return entries.filter((e): e is { label: string; value: string } => e.value != null && e.value !== "unknown");
343
340
  }
344
341
 
@@ -24,12 +24,13 @@ import { allocateOutputArtifact } from "./output-utils";
24
24
  import { formatExpandHint } from "./render-utils";
25
25
  import { ToolAbortError } from "./tool-errors";
26
26
  import { toolResult } from "./tool-result";
27
- import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, truncateHead } from "./truncate";
27
+ import { DEFAULT_MAX_BYTES, truncateHead } from "./truncate";
28
28
 
29
29
  // =============================================================================
30
30
  // Types and Constants
31
31
  // =============================================================================
32
32
 
33
+ const FETCH_DEFAULT_MAX_LINES = 300;
33
34
  // Convertible document types (markitdown supported)
34
35
  const CONVERTIBLE_MIMES = new Set([
35
36
  "application/pdf",
@@ -878,7 +879,10 @@ export class FetchTool implements AgentTool<typeof fetchSchema, FetchToolDetails
878
879
  }
879
880
 
880
881
  const result = await renderUrl(url, effectiveTimeout, raw, signal);
881
- const truncation = truncateHead(result.content, { maxBytes: DEFAULT_MAX_BYTES, maxLines: DEFAULT_MAX_LINES });
882
+ const truncation = truncateHead(result.content, {
883
+ maxBytes: DEFAULT_MAX_BYTES,
884
+ maxLines: FETCH_DEFAULT_MAX_LINES,
885
+ });
882
886
  const needsArtifact = truncation.truncated;
883
887
  let artifactId: string | undefined;
884
888
 
@@ -24,7 +24,7 @@ import { FetchTool } from "./fetch";
24
24
  import { FindTool } from "./find";
25
25
  import { GrepTool } from "./grep";
26
26
  import { NotebookTool } from "./notebook";
27
- import { wrapToolsWithMetaNotice } from "./output-meta";
27
+ import { wrapToolWithMetaNotice } from "./output-meta";
28
28
  import { PythonTool } from "./python";
29
29
  import { ReadTool } from "./read";
30
30
  import { reportFindingTool } from "./review";
@@ -328,7 +328,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
328
328
  logger.debug("Tool factory timings", { slowTools });
329
329
  }
330
330
  const tools = results.filter(r => r.tool !== null).map(r => r.tool as Tool);
331
- const wrappedTools = wrapToolsWithMetaNotice(tools);
331
+ const wrappedTools = tools.map(wrapToolWithMetaNotice);
332
332
 
333
333
  if (filteredRequestedTools !== undefined) {
334
334
  const allowed = new Set(filteredRequestedTools);
@@ -4,7 +4,13 @@
4
4
  * Tools populate details.meta using the fluent OutputMetaBuilder.
5
5
  * The tool wrapper automatically formats and appends notices at message boundary.
6
6
  */
7
- import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core";
7
+ import type {
8
+ AgentTool,
9
+ AgentToolContext,
10
+ AgentToolExecFn,
11
+ AgentToolResult,
12
+ AgentToolUpdateCallback,
13
+ } from "@oh-my-pi/pi-agent-core";
8
14
  import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
9
15
  import type { OutputSummary } from "../session/streaming-output";
10
16
  import { renderError } from "./tool-errors";
@@ -335,7 +341,8 @@ export function formatOutputNotice(meta: OutputMeta | undefined): string {
335
341
 
336
342
  if (t.nextOffset != null) {
337
343
  notice += `. Use offset=${t.nextOffset} to continue`;
338
- } else if (t.artifactId != null) {
344
+ }
345
+ if (t.artifactId != null) {
339
346
  notice += `. Full: artifact://${t.artifactId}`;
340
347
  }
341
348
 
@@ -396,31 +403,21 @@ function appendOutputNotice(
396
403
  return result;
397
404
  }
398
405
 
399
- /**
400
- * Wrap a tool to:
401
- * 1. Automatically append output notices based on details.meta
402
- * 2. Handle ToolError rendering
403
- */
404
- export function wrapToolWithMetaNotice<T extends AgentTool<any, any, any>>(tool: T): T {
405
- const originalExecute = tool.execute.bind(tool);
406
-
407
- const wrappedExecute: typeof tool.execute = async (
408
- toolCallId,
409
- params,
410
- signal,
411
- onUpdate,
412
- context,
413
- ): Promise<AgentToolResult<any, any>> => {
414
- let result: AgentToolResult<any, any>;
415
-
416
- try {
417
- result = await originalExecute(toolCallId, params, signal, onUpdate, context);
418
- } catch (e) {
419
- // Re-throw with formatted message so agent-loop sets isError flag
420
- throw new Error(renderError(e));
421
- }
406
+ const kUnwrappedExecute = Symbol("OutputMeta.UnwrappedExecute");
407
+
408
+ async function wrappedExecute(
409
+ this: AgentTool & { [kUnwrappedExecute]: AgentToolExecFn },
410
+ toolCallId: string,
411
+ params: any,
412
+ signal?: AbortSignal,
413
+ onUpdate?: AgentToolUpdateCallback,
414
+ context?: AgentToolContext,
415
+ ): Promise<AgentToolResult> {
416
+ const originalExecute = this[kUnwrappedExecute];
422
417
 
418
+ try {
423
419
  // Append notices from meta
420
+ const result = await originalExecute.call(this, toolCallId, params, signal, onUpdate, context);
424
421
  const meta = (result.details as { meta?: OutputMeta } | undefined)?.meta;
425
422
  if (meta) {
426
423
  return {
@@ -428,26 +425,36 @@ export function wrapToolWithMetaNotice<T extends AgentTool<any, any, any>>(tool:
428
425
  content: appendOutputNotice(result.content, meta) as (TextContent | ImageContent)[],
429
426
  };
430
427
  }
431
-
432
428
  return result;
433
- };
434
-
435
- // Use Proxy so property access (description, parameters, mode) stays on the original
436
- // tool. Object.create(tool) would make getters run with this=wrapper, breaking
437
- // private fields on tools like EditTool.
438
- return new Proxy(tool, {
439
- get(target, prop) {
440
- if (prop === "execute") return wrappedExecute;
441
- const value = (target as Record<string | symbol, unknown>)[prop];
442
- if (typeof value === "function") return value.bind(target);
443
- return value;
444
- },
445
- }) as T;
429
+ } catch (e) {
430
+ // Re-throw with formatted message so agent-loop sets isError flag
431
+ throw new Error(renderError(e));
432
+ }
446
433
  }
447
434
 
448
435
  /**
449
- * Wrap all tools with meta notice formatting and error handling.
436
+ * Wrap a tool to:
437
+ * 1. Automatically append output notices based on details.meta
438
+ * 2. Handle ToolError rendering
450
439
  */
451
- export function wrapToolsWithMetaNotice<T extends AgentTool<any, any, any>>(tools: T[]): T[] {
452
- return tools.map(wrapToolWithMetaNotice);
440
+ export function wrapToolWithMetaNotice<T extends AgentTool<any, any, any>>(tool: T): T {
441
+ if (kUnwrappedExecute in tool) {
442
+ return tool;
443
+ }
444
+
445
+ const originalExecute = tool.execute;
446
+
447
+ return Object.defineProperties(tool, {
448
+ [kUnwrappedExecute]: {
449
+ value: originalExecute,
450
+ enumerable: false,
451
+ configurable: true,
452
+ },
453
+ execute: {
454
+ value: wrappedExecute,
455
+ enumerable: false,
456
+ configurable: true,
457
+ writable: true,
458
+ },
459
+ });
453
460
  }