@oh-my-pi/pi-coding-agent 6.8.2 → 6.8.4

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.
@@ -1,5 +1,4 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { mkdirSync } from "node:fs";
3
2
  import path from "node:path";
4
3
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
5
4
  import { StringEnum } from "@oh-my-pi/pi-ai";
@@ -13,7 +12,6 @@ import todoWriteDescription from "../../prompts/tools/todo-write.md" with { type
13
12
  import type { RenderResultOptions } from "../custom-tools/types";
14
13
  import { renderPromptTemplate } from "../prompt-templates";
15
14
  import type { ToolSession } from "../sdk";
16
- import { ensureArtifactsDir, getArtifactsDir } from "./task/artifacts";
17
15
 
18
16
  const todoWriteSchema = Type.Object({
19
17
  todos: Type.Array(
@@ -135,10 +133,6 @@ async function loadTodoFile(filePath: string): Promise<TodoFile | null> {
135
133
  }
136
134
  }
137
135
 
138
- async function saveTodoFile(filePath: string, data: TodoFile): Promise<void> {
139
- await Bun.write(filePath, JSON.stringify(data, null, 2));
140
- }
141
-
142
136
  function formatTodoSummary(todos: TodoItem[]): string {
143
137
  if (todos.length === 0) return "Todo list cleared.";
144
138
  const completed = todos.filter((t) => t.status === "completed").length;
@@ -200,24 +194,14 @@ export class TodoWriteTool implements AgentTool<typeof todoWriteSchema, TodoWrit
200
194
  };
201
195
  }
202
196
 
203
- const artifactsDir = getArtifactsDir(sessionFile);
204
- if (!artifactsDir) {
205
- return {
206
- content: [{ type: "text", text: formatTodoSummary(todos) }],
207
- details: { todos, updatedAt, storage: "memory" },
208
- };
209
- }
210
-
211
- ensureArtifactsDir(artifactsDir);
212
- const todoPath = path.join(artifactsDir, TODO_FILE_NAME);
197
+ const todoPath = path.join(sessionFile.slice(0, -6), TODO_FILE_NAME);
213
198
  const existing = await loadTodoFile(todoPath);
214
199
  const storedTodos = existing?.todos ?? [];
215
200
  const merged = todos.length > 0 ? todos : [];
216
201
  const fileData: TodoFile = { updatedAt, todos: merged };
217
202
 
218
203
  try {
219
- mkdirSync(artifactsDir, { recursive: true });
220
- await saveTodoFile(todoPath, fileData);
204
+ await Bun.write(todoPath, JSON.stringify(fileData, null, 2));
221
205
  } catch (error) {
222
206
  logger.error("Failed to write todo file", { path: todoPath, error: String(error) });
223
207
  return {
@@ -289,3 +289,95 @@ export function truncateLine(
289
289
  }
290
290
  return { text: `${line.slice(0, maxChars)}... [truncated]`, wasTruncated: true };
291
291
  }
292
+
293
+ // =============================================================================
294
+ // Truncation notice formatting
295
+ // =============================================================================
296
+
297
+ export interface TailTruncationNoticeOptions {
298
+ /** Path to full output file (e.g., from bash/python executor) */
299
+ fullOutputPath?: string;
300
+ /** Original content for computing last line size when lastLinePartial */
301
+ originalContent?: string;
302
+ /** Additional suffix to append inside the brackets */
303
+ suffix?: string;
304
+ }
305
+
306
+ /**
307
+ * Format a truncation notice for tail-truncated output (bash, python, ssh).
308
+ * Returns empty string if not truncated.
309
+ *
310
+ * Examples:
311
+ * - "[Showing last 50KB of line 1000 (line is 2.1MB). Full output: /tmp/out.txt]"
312
+ * - "[Showing lines 500-1000 of 1000. Full output: /tmp/out.txt]"
313
+ * - "[Showing lines 500-1000 of 1000 (50KB limit). Full output: /tmp/out.txt]"
314
+ */
315
+ export function formatTailTruncationNotice(
316
+ truncation: TruncationResult,
317
+ options: TailTruncationNoticeOptions = {},
318
+ ): string {
319
+ if (!truncation.truncated) {
320
+ return "";
321
+ }
322
+
323
+ const { fullOutputPath, originalContent, suffix = "" } = options;
324
+ const startLine = truncation.totalLines - truncation.outputLines + 1;
325
+ const endLine = truncation.totalLines;
326
+ const fullOutputPart = fullOutputPath ? `. Full output: ${fullOutputPath}` : "";
327
+
328
+ let notice: string;
329
+
330
+ if (truncation.lastLinePartial) {
331
+ let lastLineSizePart = "";
332
+ if (originalContent) {
333
+ const lastLine = originalContent.split("\n").pop() || "";
334
+ lastLineSizePart = ` (line is ${formatSize(Buffer.byteLength(lastLine, "utf-8"))})`;
335
+ }
336
+ notice = `[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine}${lastLineSizePart}${fullOutputPart}${suffix}]`;
337
+ } else if (truncation.truncatedBy === "lines") {
338
+ notice = `[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}${fullOutputPart}${suffix}]`;
339
+ } else {
340
+ notice = `[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(truncation.maxBytes)} limit)${fullOutputPart}${suffix}]`;
341
+ }
342
+
343
+ return `\n\n${notice}`;
344
+ }
345
+
346
+ export interface HeadTruncationNoticeOptions {
347
+ /** 1-indexed start line number (default: 1) */
348
+ startLine?: number;
349
+ /** Total lines in the original file (for "of N" display) */
350
+ totalFileLines?: number;
351
+ }
352
+
353
+ /**
354
+ * Format a truncation notice for head-truncated output (read tool).
355
+ * Returns empty string if not truncated.
356
+ *
357
+ * Examples:
358
+ * - "[Showing lines 1-2000 of 5000. Use offset=2001 to continue]"
359
+ * - "[Showing lines 100-2099 of 5000 (50KB limit). Use offset=2100 to continue]"
360
+ */
361
+ export function formatHeadTruncationNotice(
362
+ truncation: TruncationResult,
363
+ options: HeadTruncationNoticeOptions = {},
364
+ ): string {
365
+ if (!truncation.truncated) {
366
+ return "";
367
+ }
368
+
369
+ const startLineDisplay = options.startLine ?? 1;
370
+ const totalFileLines = options.totalFileLines ?? truncation.totalLines;
371
+ const endLineDisplay = startLineDisplay + truncation.outputLines - 1;
372
+ const nextOffset = endLineDisplay + 1;
373
+
374
+ let notice: string;
375
+
376
+ if (truncation.truncatedBy === "lines") {
377
+ notice = `[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue]`;
378
+ } else {
379
+ notice = `[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatSize(truncation.maxBytes)} limit). Use offset=${nextOffset} to continue]`;
380
+ }
381
+
382
+ return `\n\n${notice}`;
383
+ }
@@ -24,7 +24,9 @@ import { convertWithMarkitdown, fetchBinary } from "./web-scrapers/utils";
24
24
  // Types and Constants
25
25
  // =============================================================================
26
26
 
27
- const DEFAULT_TIMEOUT = 20;
27
+ const MIN_TIMEOUT = 1_000;
28
+ const DEFAULT_TIMEOUT = 20_000;
29
+ const MAX_TIMEOUT = 45_000;
28
30
 
29
31
  // Convertible document types (markitdown supported)
30
32
  const CONVERTIBLE_MIMES = new Set([
@@ -109,7 +111,7 @@ async function exec(
109
111
  ): Promise<{ stdout: string; stderr: string; ok: boolean }> {
110
112
  const proc = ptree.cspawn([cmd, ...args], {
111
113
  stdin: options?.input ? "pipe" : null,
112
- timeout: options?.timeout,
114
+ timeout: options?.timeout ? options.timeout * 1000 : undefined,
113
115
  });
114
116
 
115
117
  if (options?.input) {
@@ -244,7 +246,7 @@ async function tryMdSuffix(url: string, timeout: number, signal?: AbortSignal):
244
246
  if (signal?.aborted) {
245
247
  return null;
246
248
  }
247
- const result = await loadPage(candidate, { timeout: Math.min(timeout, 5), signal });
249
+ const result = await loadPage(candidate, { timeout: Math.min(timeout, MAX_TIMEOUT), signal });
248
250
  if (result.ok && result.content.trim().length > 100 && !looksLikeHtml(result.content)) {
249
251
  return result.content;
250
252
  }
@@ -910,7 +912,7 @@ export class WebFetchTool implements AgentTool<typeof webFetchSchema, WebFetchTo
910
912
  }
911
913
 
912
914
  // Clamp timeout
913
- const effectiveTimeout = Math.min(Math.max(timeout, 1), 120);
915
+ const effectiveTimeout = Math.min(Math.max(timeout, MIN_TIMEOUT), MAX_TIMEOUT);
914
916
 
915
917
  const result = await renderUrl(url, effectiveTimeout, raw, signal);
916
918
 
@@ -1,36 +1,13 @@
1
1
  import { rm } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
3
  import * as path from "node:path";
4
- import { $ } from "bun";
4
+ import { ptree } from "@oh-my-pi/pi-utils";
5
5
  import { nanoid } from "nanoid";
6
6
  import { ensureTool } from "../../../utils/tools-manager";
7
7
  import { createRequestSignal } from "./types";
8
8
 
9
9
  const MAX_BYTES = 50 * 1024 * 1024; // 50MB for binary files
10
10
 
11
- interface ExecResult {
12
- stdout: string;
13
- stderr: string;
14
- ok: boolean;
15
- exitCode: number;
16
- }
17
-
18
- async function exec(
19
- cmd: string,
20
- args: string[],
21
- options?: { timeout?: number; input?: string | Buffer },
22
- ): Promise<ExecResult> {
23
- void options;
24
- const result = await $`${cmd} ${args}`.quiet().nothrow();
25
- const decoder = new TextDecoder();
26
- return {
27
- stdout: result.stdout ? decoder.decode(result.stdout) : "",
28
- stderr: result.stderr ? decoder.decode(result.stderr) : "",
29
- ok: result.exitCode === 0,
30
- exitCode: result.exitCode ?? -1,
31
- };
32
- }
33
-
34
11
  export interface ConvertResult {
35
12
  content: string;
36
13
  ok: boolean;
@@ -72,16 +49,16 @@ export async function convertWithMarkitdown(
72
49
 
73
50
  try {
74
51
  await Bun.write(tmpFile, content);
75
- const result = await exec(markitdown, [tmpFile], { timeout });
76
- if (!result.ok) {
77
- const stderr = result.stderr.trim();
52
+ const result = await ptree.cspawn([markitdown, tmpFile], { timeout });
53
+ const [stdout, stderr, exitCode] = await Promise.all([result.stdout.text(), result.stderr.text(), result.exited]);
54
+ if (exitCode !== 0) {
78
55
  return {
79
- content: result.stdout,
56
+ content: stdout,
80
57
  ok: false,
81
- error: stderr.length > 0 ? stderr : `markitdown failed (exit ${result.exitCode})`,
58
+ error: stderr.length > 0 ? stderr : `markitdown failed (exit ${exitCode})`,
82
59
  };
83
60
  }
84
- return { content: result.stdout, ok: true };
61
+ return { content: stdout, ok: true };
85
62
  } finally {
86
63
  try {
87
64
  await rm(tmpFile, { force: true });
@@ -64,7 +64,6 @@ function buildSystemBlocks(
64
64
 
65
65
  return buildAnthropicSystemBlocks(systemPrompt, {
66
66
  includeClaudeCodeInstruction: includeClaudeCode,
67
- includeCacheControl: auth.isOAuth,
68
67
  extraInstructions,
69
68
  });
70
69
  }
package/src/index.ts CHANGED
@@ -194,7 +194,6 @@ export {
194
194
  export { type FileSlashCommand, loadSlashCommands as discoverSlashCommands } from "./core/slash-commands";
195
195
  // Tools (detail types and utilities)
196
196
  export {
197
- type BashOperations,
198
197
  type BashToolDetails,
199
198
  DEFAULT_MAX_BYTES,
200
199
  DEFAULT_MAX_LINES,
@@ -1,7 +1,6 @@
1
1
  import * as path from "node:path";
2
2
  import { Text } from "@oh-my-pi/pi-tui";
3
3
  import { logger } from "@oh-my-pi/pi-utils";
4
- import { getArtifactsDir } from "../../../core/tools/task/artifacts";
5
4
  import { theme } from "../theme/theme";
6
5
  import type { TodoItem } from "../types";
7
6
 
@@ -40,13 +39,7 @@ export class TodoDisplayComponent {
40
39
  return;
41
40
  }
42
41
 
43
- const artifactsDir = getArtifactsDir(this.sessionFile);
44
- if (!artifactsDir) {
45
- this.todos = [];
46
- this.visible = false;
47
- return;
48
- }
49
-
42
+ const artifactsDir = this.sessionFile.slice(0, -6); // strip .jsonl extension
50
43
  const todoPath = path.join(artifactsDir, TODO_FILE_NAME);
51
44
  const data = await loadTodoFile(todoPath);
52
45
  this.todos = data?.todos ?? [];
@@ -707,7 +707,7 @@ class TreeList implements Component {
707
707
  this.searchQuery = this.searchQuery.slice(0, -1);
708
708
  this.applyFilter();
709
709
  }
710
- } else if (keyData === "l" && !this.searchQuery) {
710
+ } else if (matchesKey(keyData, "shift+l") && !this.searchQuery) {
711
711
  const selected = this.filteredNodes[this.selectedIndex];
712
712
  if (selected && this.onLabelEdit) {
713
713
  this.onLabelEdit(selected.node.entry.id, selected.node.label);
@@ -821,7 +821,7 @@ export class TreeSelectorComponent extends Container {
821
821
  new TruncatedText(
822
822
  theme.fg(
823
823
  "muted",
824
- " Up/Down: move. Left/Right: page. l: label. Ctrl+O/Shift+Ctrl+O: filter. Alt+D/T/U/L/A: filter. Type to search",
824
+ " Up/Down: move. Left/Right: page. Shift+L: label. Ctrl+O/Shift+Ctrl+O: filter. Alt+D/T/U/L/A: filter. Type to search",
825
825
  ),
826
826
  0,
827
827
  0,
@@ -28,7 +28,6 @@ import { getRecentSessions } from "../../core/session-manager";
28
28
  import type { SettingsManager } from "../../core/settings-manager";
29
29
  import { loadSlashCommands } from "../../core/slash-commands";
30
30
  import { setTerminalTitle } from "../../core/title-generator";
31
- import { getArtifactsDir } from "../../core/tools/task/artifacts";
32
31
  import { VoiceSupervisor } from "../../core/voice-supervisor";
33
32
  import type { AssistantMessageComponent } from "./components/assistant-message";
34
33
  import type { BashExecutionComponent } from "./components/bash-execution";
@@ -384,15 +383,6 @@ export class InteractiveMode implements InteractiveModeContext {
384
383
 
385
384
  // Initial top border update
386
385
  this.updateEditorTopBorder();
387
-
388
- if (!startupQuiet) {
389
- const templateNames = this.session.promptTemplates.map((template) => template.name).sort();
390
- if (templateNames.length > 0) {
391
- const preview = templateNames.slice(0, 3).join(", ");
392
- const suffix = templateNames.length > 3 ? ` +${templateNames.length - 3} more` : "";
393
- this.showStatus(`Loaded prompt templates: ${preview}${suffix}`);
394
- }
395
- }
396
386
  }
397
387
 
398
388
  async getUserInput(): Promise<{ text: string; images?: ImageContent[] }> {
@@ -481,11 +471,7 @@ export class InteractiveMode implements InteractiveModeContext {
481
471
  this.renderTodoList();
482
472
  return;
483
473
  }
484
- const artifactsDir = getArtifactsDir(sessionFile);
485
- if (!artifactsDir) {
486
- this.renderTodoList();
487
- return;
488
- }
474
+ const artifactsDir = sessionFile.slice(0, -6);
489
475
  const todoPath = path.join(artifactsDir, TODO_FILE_NAME);
490
476
  const file = Bun.file(todoPath);
491
477
  if (!(await file.exists())) {
@@ -32,33 +32,40 @@ function selectPreferredImageMimeType(mimeTypes: string[]): string | null {
32
32
  }
33
33
 
34
34
  export async function copyToClipboard(text: string): Promise<void> {
35
- let promise: Promise<void>;
35
+ const p = platform();
36
+ const timeout = 5000;
37
+
36
38
  try {
37
- switch (platform()) {
38
- case "darwin":
39
- promise = $`pbcopy ${text}`.quiet().then(() => void 0);
40
- break;
41
- case "win32":
42
- promise = $`clip ${text}`.quiet().then(() => void 0);
43
- break;
44
- case "linux":
45
- if (isWaylandSession()) {
46
- $`wl-copy ${text}`.quiet(); // fire and forget
39
+ if (p === "darwin") {
40
+ await Bun.spawn(["pbcopy"], { stdin: Buffer.from(text), timeout }).exited;
41
+ } else if (p === "win32") {
42
+ await Bun.spawn(["clip"], { stdin: Buffer.from(text), timeout }).exited;
43
+ } else {
44
+ const wayland = isWaylandSession();
45
+ if (wayland) {
46
+ const wlCopyPath = Bun.which("wl-copy");
47
+ if (wlCopyPath) {
48
+ // Fire-and-forget: wl-copy may not exit promptly, so we unref to avoid blocking
49
+ void Bun.spawn([wlCopyPath], { stdin: Buffer.from(text), timeout }).unref();
47
50
  return;
48
- } else {
49
- promise = $`xclip -selection clipboard -t text/plain -i ${text}`.quiet().then(() => void 0);
50
51
  }
51
- break;
52
- default:
53
- throw new Error(`Unsupported platform: ${platform()}`);
52
+ }
53
+
54
+ // Linux - try xclip first, fall back to xsel
55
+ try {
56
+ await Bun.spawn(["xclip", "-selection", "clipboard"], { stdin: Buffer.from(text), timeout }).exited;
57
+ } catch {
58
+ await Bun.spawn(["xsel", "--clipboard", "--input"], { stdin: Buffer.from(text), timeout }).exited;
59
+ }
54
60
  }
55
61
  } catch (error) {
56
- if (error instanceof Error) {
57
- throw new Error(`Failed to copy to clipboard: ${error.message}`, { cause: error });
62
+ const msg = error instanceof Error ? error.message : String(error);
63
+ if (p === "linux") {
64
+ const tools = isWaylandSession() ? "wl-copy, xclip, or xsel" : "xclip or xsel";
65
+ throw new Error(`Failed to copy to clipboard. Install ${tools}: ${msg}`);
58
66
  }
59
- throw new Error(`Failed to copy to clipboard: ${String(error)}`, { cause: error });
67
+ throw new Error(`Failed to copy to clipboard: ${msg}`);
60
68
  }
61
- await Promise.race([promise, Bun.sleep(3000)]);
62
69
  }
63
70
 
64
71
  export interface ClipboardImage {
@@ -1,112 +0,0 @@
1
- /**
2
- * Session artifacts for subagent outputs.
3
- *
4
- * When a session exists, writes agent outputs to a sibling directory.
5
- * Otherwise uses temp files that are cleaned up after execution.
6
- */
7
-
8
- import * as fs from "node:fs";
9
- import * as os from "node:os";
10
- import * as path from "node:path";
11
- import { nanoid } from "nanoid";
12
-
13
- /**
14
- * Derive artifacts directory from session file path.
15
- *
16
- * /path/to/sessions/project/2026-01-01T14-28-11-636Z_uuid.jsonl
17
- * → /path/to/sessions/project/2026-01-01T14-28-11-636Z_uuid/
18
- */
19
- export function getArtifactsDir(sessionFile: string | null): string | null {
20
- if (!sessionFile) return null;
21
- // Strip .jsonl extension to get directory path
22
- if (sessionFile.endsWith(".jsonl")) {
23
- return sessionFile.slice(0, -6);
24
- }
25
- return sessionFile;
26
- }
27
-
28
- /**
29
- * Ensure artifacts directory exists.
30
- */
31
- export function ensureArtifactsDir(dir: string): void {
32
- if (!fs.existsSync(dir)) {
33
- fs.mkdirSync(dir, { recursive: true });
34
- }
35
- }
36
-
37
- /**
38
- * Generate artifact file paths for an agent run.
39
- */
40
- export function getArtifactPaths(
41
- dir: string,
42
- taskId: string,
43
- ): { inputPath: string; outputPath: string; jsonlPath: string } {
44
- return {
45
- inputPath: path.join(dir, `${taskId}.in.md`),
46
- outputPath: path.join(dir, `${taskId}.out.md`),
47
- jsonlPath: path.join(dir, `${taskId}.jsonl`),
48
- };
49
- }
50
-
51
- /**
52
- * Write artifacts for an agent run.
53
- */
54
- export async function writeArtifacts(
55
- dir: string,
56
- taskId: string,
57
- input: string,
58
- output: string,
59
- jsonlEvents?: string[],
60
- ): Promise<{ inputPath: string; outputPath: string; jsonlPath?: string }> {
61
- ensureArtifactsDir(dir);
62
-
63
- const paths = getArtifactPaths(dir, taskId);
64
-
65
- // Write input
66
- await Bun.write(paths.inputPath, input);
67
-
68
- // Write output
69
- await Bun.write(paths.outputPath, output);
70
-
71
- // Write JSONL if events provided
72
- if (jsonlEvents && jsonlEvents.length > 0) {
73
- await Bun.write(paths.jsonlPath, jsonlEvents.join("\n"));
74
- return paths;
75
- }
76
-
77
- return { inputPath: paths.inputPath, outputPath: paths.outputPath };
78
- }
79
-
80
- /**
81
- * Create a temporary artifacts directory.
82
- */
83
- export function createTempArtifactsDir(runId?: string): string {
84
- const id = runId || nanoid();
85
- const dir = path.join(os.tmpdir(), `omp-task-${id}`);
86
- ensureArtifactsDir(dir);
87
- return dir;
88
- }
89
-
90
- /**
91
- * Clean up temporary artifacts.
92
- */
93
- export async function cleanupTempArtifacts(paths: string[]): Promise<void> {
94
- for (const p of paths) {
95
- try {
96
- await fs.promises.unlink(p);
97
- } catch {
98
- // Ignore cleanup errors
99
- }
100
- }
101
- }
102
-
103
- /**
104
- * Clean up a temporary directory and its contents.
105
- */
106
- export async function cleanupTempDir(dir: string): Promise<void> {
107
- try {
108
- await fs.promises.rm(dir, { recursive: true, force: true });
109
- } catch {
110
- // Ignore cleanup errors
111
- }
112
- }