@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 +2 -0
- package/package.json +5 -4
- package/src/core/session-manager.ts +98 -69
- package/src/modes/rpc/rpc-mode.ts +8 -7
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oh-my-pi/pi-coding-agent",
|
|
3
|
-
"version": "3.
|
|
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.
|
|
43
|
-
"@oh-my-pi/pi-ai": "3.
|
|
44
|
-
"@oh-my-pi/pi-tui": "3.
|
|
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 =
|
|
200
|
+
const id = nanoid(8);
|
|
200
201
|
if (!byId.has(id)) return id;
|
|
201
202
|
}
|
|
202
|
-
//
|
|
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
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
-
/**
|
|
470
|
-
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
|
545
|
+
return [];
|
|
482
546
|
}
|
|
483
547
|
}
|
|
484
548
|
|
|
485
|
-
/**
|
|
486
|
-
export
|
|
487
|
-
|
|
488
|
-
path
|
|
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
|
-
|
|
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 ??
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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:
|
|
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:
|
|
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:
|
|
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 =
|
|
156
|
+
const id = nanoid();
|
|
156
157
|
return new Promise((resolve, reject) => {
|
|
157
158
|
pendingHookRequests.set(id, {
|
|
158
159
|
resolve: (response: RpcHookUIResponse) => {
|