@oh-my-pi/pi-coding-agent 3.5.1337 → 3.6.1337

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,8 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [3.6.1337] - 2026-01-03
6
+
5
7
  ## [3.5.1337] - 2026-01-03
6
8
 
7
9
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "3.5.1337",
3
+ "version": "3.6.1337",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "ompConfig": {
@@ -39,9 +39,9 @@
39
39
  "prepublishOnly": "bun run generate-template && bun run clean && bun run build"
40
40
  },
41
41
  "dependencies": {
42
- "@oh-my-pi/pi-agent-core": "3.5.1337",
43
- "@oh-my-pi/pi-ai": "3.5.1337",
44
- "@oh-my-pi/pi-tui": "3.5.1337",
42
+ "@oh-my-pi/pi-agent-core": "3.6.1337",
43
+ "@oh-my-pi/pi-ai": "3.6.1337",
44
+ "@oh-my-pi/pi-tui": "3.6.1337",
45
45
  "@sinclair/typebox": "^0.34.46",
46
46
  "ajv": "^8.17.1",
47
47
  "chalk": "^5.5.0",
@@ -52,6 +52,7 @@
52
52
  "highlight.js": "^11.11.1",
53
53
  "marked": "^15.0.12",
54
54
  "minimatch": "^10.1.1",
55
+ "nanoid": "^5.1.6",
55
56
  "node-html-parser": "^6.1.13",
56
57
  "smol-toml": "^1.6.0",
57
58
  "strip-ansi": "^7.1.2",
@@ -13,6 +13,7 @@ import {
13
13
  import { join, resolve } from "node:path";
14
14
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
15
15
  import type { ImageContent, Message, TextContent } from "@oh-my-pi/pi-ai";
16
+ import { nanoid } from "nanoid";
16
17
  import { getAgentDir as getDefaultAgentDir } from "../config";
17
18
  import {
18
19
  type BashExecutionMessage,
@@ -196,11 +197,10 @@ export type ReadonlySessionManager = Pick<
196
197
  /** Generate a unique short ID (8 hex chars, collision-checked) */
197
198
  function generateId(byId: { has(id: string): boolean }): string {
198
199
  for (let i = 0; i < 100; i++) {
199
- const id = crypto.randomUUID().slice(0, 8);
200
+ const id = nanoid(8);
200
201
  if (!byId.has(id)) return id;
201
202
  }
202
- // Fallback to full UUID if somehow we have collisions
203
- return crypto.randomUUID();
203
+ return nanoid(); // fallback to full nanoid
204
204
  }
205
205
 
206
206
  /** Migrate v1 → v2: add id/parentId tree structure. Mutates in place. */
@@ -451,42 +451,105 @@ export function loadEntriesFromFile(filePath: string): FileEntry[] {
451
451
  return entries;
452
452
  }
453
453
 
454
- function isValidSessionFile(filePath: string): boolean {
455
- try {
456
- const fd = openSync(filePath, "r");
457
- const buffer = Buffer.alloc(512);
458
- const bytesRead = readSync(fd, buffer, 0, 512, 0);
459
- closeSync(fd);
460
- const firstLine = buffer.toString("utf8", 0, bytesRead).split("\n")[0];
461
- if (!firstLine) return false;
462
- const header = JSON.parse(firstLine);
463
- return header.type === "session" && typeof header.id === "string";
464
- } catch {
465
- return false;
454
+ /**
455
+ * Lightweight metadata for a session file, used in session picker UI.
456
+ * Uses lazy getters to defer string formatting until actually displayed.
457
+ */
458
+ class RecentSessionInfo {
459
+ readonly path: string;
460
+ readonly mtime: number;
461
+
462
+ #fullName: string | undefined;
463
+ #name: string | undefined;
464
+ #timeAgo: string | undefined;
465
+
466
+ constructor(path: string, mtime: number, header: Record<string, unknown>) {
467
+ this.path = path;
468
+ this.mtime = mtime;
469
+
470
+ // Extract title from session header, falling back to id if title is missing
471
+ const trystr = (v: unknown) => (typeof v === "string" ? v : undefined);
472
+ this.#fullName = trystr(header.title) ?? trystr(header.id);
473
+ }
474
+
475
+ /** Full session name from header, or filename without extension as fallback */
476
+ get fullName(): string {
477
+ if (this.#fullName) return this.#fullName;
478
+ this.#fullName = this.path.split("/").pop()?.replace(".jsonl", "") ?? "Unknown";
479
+ return this.#fullName;
480
+ }
481
+
482
+ /** Truncated name for display (max 40 chars) */
483
+ get name(): string {
484
+ if (this.#name) return this.#name;
485
+ const fullName = this.fullName;
486
+ this.#name = fullName.length <= 40 ? fullName : `${fullName.slice(0, 37)}...`;
487
+ return this.#name;
488
+ }
489
+
490
+ /** Human-readable relative time (e.g., "2 hours ago") */
491
+ get timeAgo(): string {
492
+ if (this.#timeAgo) return this.#timeAgo;
493
+ this.#timeAgo = formatTimeAgo(new Date(this.mtime));
494
+ return this.#timeAgo;
466
495
  }
467
496
  }
468
497
 
469
- /** Exported for testing */
470
- export function findMostRecentSession(sessionDir: string): string | null {
498
+ /**
499
+ * Reads all session files from the directory and returns them sorted by mtime (newest first).
500
+ * Uses low-level file I/O to efficiently read only the first 512 bytes of each file
501
+ * to extract the JSON header without loading entire session logs into memory.
502
+ */
503
+ function getSortedSessions(sessionDir: string): RecentSessionInfo[] {
471
504
  try {
472
- const files = readdirSync(sessionDir)
473
- .filter((f) => f.endsWith(".jsonl"))
474
- .map((f) => join(sessionDir, f))
475
- .filter(isValidSessionFile)
476
- .map((path) => ({ path, mtime: statSync(path).mtime }))
477
- .sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
478
-
479
- return files[0]?.path || null;
505
+ // Reusable buffer for reading file headers
506
+ const buf = Buffer.allocUnsafe(512);
507
+
508
+ /**
509
+ * Reads the first line (JSON header) from an open file descriptor.
510
+ * Returns null if the file is empty or doesn't start with valid JSON.
511
+ */
512
+ const readHeader = (fd: number) => {
513
+ const bytesRead = readSync(fd, buf, 0, 512, 0);
514
+ if (bytesRead === 0) return null;
515
+ const sub = buf.subarray(0, bytesRead);
516
+ // Quick check: first char must be '{' for valid JSON object
517
+ if (sub.at(0) !== "{".charCodeAt(0)) return null;
518
+ // Find end of first JSON line
519
+ const eol = sub.indexOf("}\n");
520
+ if (eol <= 0) return null;
521
+ return JSON.parse(sub.toString("utf8", 0, eol + 1));
522
+ };
523
+
524
+ return readdirSync(sessionDir)
525
+ .map((f) => {
526
+ try {
527
+ if (!f.endsWith(".jsonl")) return null;
528
+ const path = join(sessionDir, f);
529
+ const fd = openSync(path, "r");
530
+ try {
531
+ const header = readHeader(fd);
532
+ if (!header) return null;
533
+ const mtime = statSync(path).mtimeMs;
534
+ return new RecentSessionInfo(path, mtime, header);
535
+ } finally {
536
+ closeSync(fd);
537
+ }
538
+ } catch {
539
+ return null;
540
+ }
541
+ })
542
+ .filter((x) => x !== null)
543
+ .sort((a, b) => b.mtime - a.mtime); // Sort newest first
480
544
  } catch {
481
- return null;
545
+ return [];
482
546
  }
483
547
  }
484
548
 
485
- /** Recent session info for display */
486
- export interface RecentSessionInfo {
487
- name: string;
488
- path: string;
489
- timeAgo: string;
549
+ /** Exported for testing */
550
+ export function findMostRecentSession(sessionDir: string): string | null {
551
+ const sessions = getSortedSessions(sessionDir);
552
+ return sessions[0]?.path || null;
490
553
  }
491
554
 
492
555
  /** Format a time difference as a human-readable string */
@@ -506,41 +569,7 @@ function formatTimeAgo(date: Date): string {
506
569
 
507
570
  /** Get recent sessions for display in welcome screen */
508
571
  export function getRecentSessions(sessionDir: string, limit = 3): RecentSessionInfo[] {
509
- try {
510
- const files = readdirSync(sessionDir)
511
- .filter((f) => f.endsWith(".jsonl"))
512
- .map((f) => join(sessionDir, f))
513
- .filter(isValidSessionFile)
514
- .map((path) => {
515
- const stat = statSync(path);
516
- // Try to get session title or id from first line
517
- let name = path.split("/").pop()?.replace(".jsonl", "") ?? "Unknown";
518
- try {
519
- const content = readFileSync(path, "utf-8");
520
- const firstLine = content.split("\n")[0];
521
- if (firstLine) {
522
- const header = JSON.parse(firstLine) as SessionHeader;
523
- if (header.type === "session") {
524
- // Prefer title over id
525
- name = header.title ?? header.id ?? name;
526
- }
527
- }
528
- } catch {
529
- // Use filename as fallback
530
- }
531
- return { path, name, mtime: stat.mtime };
532
- })
533
- .sort((a, b) => b.mtime.getTime() - a.mtime.getTime())
534
- .slice(0, limit);
535
-
536
- return files.map((f) => ({
537
- name: f.name.length > 40 ? `${f.name.slice(0, 37)}...` : f.name,
538
- path: f.path,
539
- timeAgo: formatTimeAgo(f.mtime),
540
- }));
541
- } catch {
542
- return [];
543
- }
572
+ return getSortedSessions(sessionDir).slice(0, limit);
544
573
  }
545
574
 
546
575
  /**
@@ -588,7 +617,7 @@ export class SessionManager {
588
617
  if (existsSync(this.sessionFile)) {
589
618
  this.fileEntries = loadEntriesFromFile(this.sessionFile);
590
619
  const header = this.fileEntries.find((e) => e.type === "session") as SessionHeader | undefined;
591
- this.sessionId = header?.id ?? crypto.randomUUID();
620
+ this.sessionId = header?.id ?? nanoid();
592
621
  this.sessionTitle = header?.title;
593
622
 
594
623
  if (migrateToCurrentVersion(this.fileEntries)) {
@@ -603,7 +632,7 @@ export class SessionManager {
603
632
  }
604
633
 
605
634
  newSession(options?: NewSessionOptions): string | undefined {
606
- this.sessionId = crypto.randomUUID();
635
+ this.sessionId = nanoid();
607
636
  const timestamp = new Date().toISOString();
608
637
  const header: SessionHeader = {
609
638
  type: "session",
@@ -1081,7 +1110,7 @@ export class SessionManager {
1081
1110
  // Filter out LabelEntry from path - we'll recreate them from the resolved map
1082
1111
  const pathWithoutLabels = path.filter((e) => e.type !== "label");
1083
1112
 
1084
- const newSessionId = crypto.randomUUID();
1113
+ const newSessionId = nanoid();
1085
1114
  const timestamp = new Date().toISOString();
1086
1115
  const fileTimestamp = timestamp.replace(/[:.]/g, "-");
1087
1116
  const newSessionFile = join(this.getSessionDir(), `${fileTimestamp}_${newSessionId}.jsonl`);
@@ -11,6 +11,7 @@
11
11
  * - Hook UI: Hook UI requests are emitted, client responds with hook_ui_response
12
12
  */
13
13
 
14
+ import { nanoid } from "nanoid";
14
15
  import type { AgentSession } from "../../core/agent-session";
15
16
  import type { HookUIContext } from "../../core/hooks/index";
16
17
  import { logger } from "../../core/logger";
@@ -52,7 +53,7 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
52
53
  */
53
54
  const createHookUIContext = (): HookUIContext => ({
54
55
  async select(title: string, options: string[]): Promise<string | undefined> {
55
- const id = crypto.randomUUID();
56
+ const id = nanoid();
56
57
  return new Promise((resolve, reject) => {
57
58
  pendingHookRequests.set(id, {
58
59
  resolve: (response: RpcHookUIResponse) => {
@@ -71,7 +72,7 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
71
72
  },
72
73
 
73
74
  async confirm(title: string, message: string): Promise<boolean> {
74
- const id = crypto.randomUUID();
75
+ const id = nanoid();
75
76
  return new Promise((resolve, reject) => {
76
77
  pendingHookRequests.set(id, {
77
78
  resolve: (response: RpcHookUIResponse) => {
@@ -90,7 +91,7 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
90
91
  },
91
92
 
92
93
  async input(title: string, placeholder?: string): Promise<string | undefined> {
93
- const id = crypto.randomUUID();
94
+ const id = nanoid();
94
95
  return new Promise((resolve, reject) => {
95
96
  pendingHookRequests.set(id, {
96
97
  resolve: (response: RpcHookUIResponse) => {
@@ -112,7 +113,7 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
112
113
  // Fire and forget - no response needed
113
114
  output({
114
115
  type: "hook_ui_request",
115
- id: crypto.randomUUID(),
116
+ id: nanoid(),
116
117
  method: "notify",
117
118
  message,
118
119
  notifyType: type,
@@ -123,7 +124,7 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
123
124
  // Fire and forget - no response needed
124
125
  output({
125
126
  type: "hook_ui_request",
126
- id: crypto.randomUUID(),
127
+ id: nanoid(),
127
128
  method: "setStatus",
128
129
  statusKey: key,
129
130
  statusText: text,
@@ -139,7 +140,7 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
139
140
  // Fire and forget - host can implement editor control
140
141
  output({
141
142
  type: "hook_ui_request",
142
- id: crypto.randomUUID(),
143
+ id: nanoid(),
143
144
  method: "set_editor_text",
144
145
  text,
145
146
  } as RpcHookUIRequest);
@@ -152,7 +153,7 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
152
153
  },
153
154
 
154
155
  async editor(title: string, prefill?: string): Promise<string | undefined> {
155
- const id = crypto.randomUUID();
156
+ const id = nanoid();
156
157
  return new Promise((resolve, reject) => {
157
158
  pendingHookRequests.set(id, {
158
159
  resolve: (response: RpcHookUIResponse) => {