@oh-my-pi/pi-coding-agent 3.5.1337 → 3.8.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 +29 -0
- package/package.json +5 -4
- package/src/core/bash-executor.ts +115 -154
- package/src/core/index.ts +2 -0
- package/src/core/session-manager.ts +113 -74
- package/src/core/tools/edit-diff.ts +45 -33
- package/src/core/tools/edit.ts +70 -182
- package/src/core/tools/find.ts +141 -160
- package/src/core/tools/index.ts +10 -9
- package/src/core/tools/ls.ts +64 -82
- package/src/core/tools/lsp/client.ts +63 -0
- package/src/core/tools/lsp/edits.ts +13 -4
- package/src/core/tools/lsp/index.ts +191 -85
- package/src/core/tools/notebook.ts +89 -144
- package/src/core/tools/read.ts +110 -158
- package/src/core/tools/write.ts +22 -115
- package/src/core/utils.ts +187 -0
- package/src/modes/interactive/components/tool-execution.ts +14 -14
- package/src/modes/interactive/interactive-mode.ts +23 -54
- package/src/modes/rpc/rpc-mode.ts +8 -7
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [3.8.1337] - 2026-01-04
|
|
6
|
+
### Added
|
|
7
|
+
|
|
8
|
+
- Added automatic browser opening after exporting session to HTML
|
|
9
|
+
- Added automatic browser opening after sharing session as a Gist
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Fixed session titles not persisting to file when set before first flush
|
|
14
|
+
|
|
15
|
+
## [3.7.1337] - 2026-01-04
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- Added `EditMatchError` class for structured error handling in edit operations
|
|
19
|
+
- Added `utils` module export with `once` and `untilAborted` helper functions
|
|
20
|
+
- Added in-memory LSP content sync via `syncContent` and `notifySaved` client methods
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- Refactored LSP integration to use writethrough callbacks for edit and write tools, improving performance by syncing content in-memory before disk writes
|
|
25
|
+
- Simplified FileDiagnosticsResult interface with renamed fields: `diagnostics` → `messages`, `hasErrors` → `errored`, `serverName` → `server`
|
|
26
|
+
- Session title generation now triggers before sending the first message rather than after agent work begins
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- Fixed potential text decoding issues in bash executor by using streaming TextDecoder instead of Buffer.toString()
|
|
31
|
+
|
|
32
|
+
## [3.6.1337] - 2026-01-03
|
|
33
|
+
|
|
5
34
|
## [3.5.1337] - 2026-01-03
|
|
6
35
|
|
|
7
36
|
### Added
|
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.8.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.8.1337",
|
|
43
|
+
"@oh-my-pi/pi-ai": "3.8.1337",
|
|
44
|
+
"@oh-my-pi/pi-tui": "3.8.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",
|
|
@@ -14,6 +14,7 @@ import stripAnsi from "strip-ansi";
|
|
|
14
14
|
import { getShellConfig, killProcessTree, sanitizeBinaryOutput } from "../utils/shell";
|
|
15
15
|
import { getOrCreateSnapshot, getSnapshotSourceCommand } from "../utils/shell-snapshot";
|
|
16
16
|
import { DEFAULT_MAX_BYTES, truncateTail } from "./tools/truncate";
|
|
17
|
+
import { ScopeSignal } from "./utils";
|
|
17
18
|
|
|
18
19
|
// ============================================================================
|
|
19
20
|
// Types
|
|
@@ -47,6 +48,70 @@ export interface BashResult {
|
|
|
47
48
|
// Implementation
|
|
48
49
|
// ============================================================================
|
|
49
50
|
|
|
51
|
+
function createSanitizer(): TransformStream<Uint8Array, string> {
|
|
52
|
+
const decoder = new TextDecoder();
|
|
53
|
+
return new TransformStream({
|
|
54
|
+
transform(chunk, controller) {
|
|
55
|
+
const text = sanitizeBinaryOutput(stripAnsi(decoder.decode(chunk, { stream: true }))).replace(/\r/g, "");
|
|
56
|
+
controller.enqueue(text);
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function createOutputSink(
|
|
62
|
+
spillThreshold: number,
|
|
63
|
+
maxBuffer: number,
|
|
64
|
+
onChunk?: (text: string) => void,
|
|
65
|
+
): WritableStream<string> & {
|
|
66
|
+
dump: (annotation?: string) => { output: string; truncated: boolean; fullOutputPath?: string };
|
|
67
|
+
} {
|
|
68
|
+
const chunks: string[] = [];
|
|
69
|
+
let chunkBytes = 0;
|
|
70
|
+
let totalBytes = 0;
|
|
71
|
+
let fullOutputPath: string | undefined;
|
|
72
|
+
let fullOutputStream: WriteStream | undefined;
|
|
73
|
+
|
|
74
|
+
const sink = new WritableStream<string>({
|
|
75
|
+
write(text) {
|
|
76
|
+
totalBytes += text.length;
|
|
77
|
+
|
|
78
|
+
// Spill to temp file if needed
|
|
79
|
+
if (totalBytes > spillThreshold && !fullOutputPath) {
|
|
80
|
+
fullOutputPath = join(tmpdir(), `omp-${crypto.randomUUID()}.buffer`);
|
|
81
|
+
const ts = createWriteStream(fullOutputPath);
|
|
82
|
+
chunks.forEach((c) => {
|
|
83
|
+
ts.write(c);
|
|
84
|
+
});
|
|
85
|
+
fullOutputStream = ts;
|
|
86
|
+
}
|
|
87
|
+
fullOutputStream?.write(text);
|
|
88
|
+
|
|
89
|
+
// Rolling buffer
|
|
90
|
+
chunks.push(text);
|
|
91
|
+
chunkBytes += text.length;
|
|
92
|
+
while (chunkBytes > maxBuffer && chunks.length > 1) {
|
|
93
|
+
chunkBytes -= chunks.shift()!.length;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
onChunk?.(text);
|
|
97
|
+
},
|
|
98
|
+
close() {
|
|
99
|
+
fullOutputStream?.end();
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
return Object.assign(sink, {
|
|
104
|
+
dump(annotation?: string) {
|
|
105
|
+
if (annotation) {
|
|
106
|
+
chunks.push(`\n\n${annotation}`);
|
|
107
|
+
}
|
|
108
|
+
const full = chunks.join("");
|
|
109
|
+
const { content, truncated } = truncateTail(full);
|
|
110
|
+
return { output: truncated ? content : full, truncated, fullOutputPath: fullOutputPath };
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
50
115
|
/**
|
|
51
116
|
* Execute a bash command with optional streaming and cancellation support.
|
|
52
117
|
*
|
|
@@ -72,165 +137,61 @@ export async function executeBash(command: string, options?: BashExecutorOptions
|
|
|
72
137
|
const prefixedCommand = prefix ? `${prefix} ${command}` : command;
|
|
73
138
|
const finalCommand = `${snapshotPrefix}${prefixedCommand}`;
|
|
74
139
|
|
|
75
|
-
|
|
76
|
-
const child: Subprocess = Bun.spawn([shell, ...args, finalCommand], {
|
|
77
|
-
cwd: options?.cwd,
|
|
78
|
-
stdin: "ignore",
|
|
79
|
-
stdout: "pipe",
|
|
80
|
-
stderr: "pipe",
|
|
81
|
-
env,
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
// Track sanitized output for truncation
|
|
85
|
-
const outputChunks: string[] = [];
|
|
86
|
-
let outputBytes = 0;
|
|
87
|
-
const maxOutputBytes = DEFAULT_MAX_BYTES * 2;
|
|
88
|
-
|
|
89
|
-
// Temp file for large output
|
|
90
|
-
let tempFilePath: string | undefined;
|
|
91
|
-
let tempFileStream: WriteStream | undefined;
|
|
92
|
-
let totalBytes = 0;
|
|
93
|
-
let timedOut = false;
|
|
94
|
-
|
|
95
|
-
// Handle abort signal and timeout
|
|
96
|
-
const abortHandler = () => {
|
|
97
|
-
killProcessTree(child.pid);
|
|
98
|
-
};
|
|
99
|
-
|
|
100
|
-
// Set up timeout if specified
|
|
101
|
-
let timeoutHandle: Timer | undefined;
|
|
102
|
-
if (options?.timeout && options.timeout > 0) {
|
|
103
|
-
timeoutHandle = setTimeout(() => {
|
|
104
|
-
timedOut = true;
|
|
105
|
-
abortHandler();
|
|
106
|
-
}, options.timeout);
|
|
107
|
-
}
|
|
140
|
+
using signal = new ScopeSignal(options);
|
|
108
141
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
exitCode: undefined,
|
|
117
|
-
cancelled: true,
|
|
118
|
-
truncated: false,
|
|
119
|
-
});
|
|
120
|
-
return;
|
|
121
|
-
}
|
|
122
|
-
options.signal.addEventListener("abort", abortHandler, { once: true });
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
const handleData = (data: Buffer) => {
|
|
126
|
-
totalBytes += data.length;
|
|
127
|
-
|
|
128
|
-
// Sanitize once at the source: strip ANSI, replace binary garbage, normalize newlines
|
|
129
|
-
const text = sanitizeBinaryOutput(stripAnsi(data.toString())).replace(/\r/g, "");
|
|
130
|
-
|
|
131
|
-
// Start writing to temp file if exceeds threshold
|
|
132
|
-
if (totalBytes > DEFAULT_MAX_BYTES && !tempFilePath) {
|
|
133
|
-
const randomId = crypto.getRandomValues(new Uint8Array(8));
|
|
134
|
-
const id = Array.from(randomId, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
135
|
-
tempFilePath = join(tmpdir(), `omp-bash-${id}.log`);
|
|
136
|
-
tempFileStream = createWriteStream(tempFilePath);
|
|
137
|
-
// Write already-buffered chunks to temp file
|
|
138
|
-
for (const chunk of outputChunks) {
|
|
139
|
-
tempFileStream.write(chunk);
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
+
const child: Subprocess = Bun.spawn([shell, ...args, finalCommand], {
|
|
143
|
+
cwd: options?.cwd,
|
|
144
|
+
stdin: "ignore",
|
|
145
|
+
stdout: "pipe",
|
|
146
|
+
stderr: "pipe",
|
|
147
|
+
env,
|
|
148
|
+
});
|
|
142
149
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
150
|
+
signal.catch(() => {
|
|
151
|
+
killProcessTree(child.pid);
|
|
152
|
+
});
|
|
146
153
|
|
|
147
|
-
|
|
148
|
-
outputChunks.push(text);
|
|
149
|
-
outputBytes += text.length;
|
|
150
|
-
while (outputBytes > maxOutputBytes && outputChunks.length > 1) {
|
|
151
|
-
const removed = outputChunks.shift()!;
|
|
152
|
-
outputBytes -= removed.length;
|
|
153
|
-
}
|
|
154
|
+
const sink = createOutputSink(DEFAULT_MAX_BYTES, DEFAULT_MAX_BYTES * 2, options?.onChunk);
|
|
154
155
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
};
|
|
160
|
-
|
|
161
|
-
// Read streams asynchronously
|
|
162
|
-
(async () => {
|
|
156
|
+
const writer = sink.getWriter();
|
|
157
|
+
try {
|
|
158
|
+
async function pumpStream(readable: ReadableStream<Uint8Array>) {
|
|
159
|
+
const reader = readable.pipeThrough(createSanitizer()).getReader();
|
|
163
160
|
try {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
(async () => {
|
|
169
|
-
while (true) {
|
|
170
|
-
const { done, value } = await stdoutReader.read();
|
|
171
|
-
if (done) break;
|
|
172
|
-
handleData(Buffer.from(value));
|
|
173
|
-
}
|
|
174
|
-
})(),
|
|
175
|
-
(async () => {
|
|
176
|
-
while (true) {
|
|
177
|
-
const { done, value } = await stderrReader.read();
|
|
178
|
-
if (done) break;
|
|
179
|
-
handleData(Buffer.from(value));
|
|
180
|
-
}
|
|
181
|
-
})(),
|
|
182
|
-
]);
|
|
183
|
-
|
|
184
|
-
const exitCode = await child.exited;
|
|
185
|
-
|
|
186
|
-
// Clean up
|
|
187
|
-
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
188
|
-
if (options?.signal) {
|
|
189
|
-
options.signal.removeEventListener("abort", abortHandler);
|
|
190
|
-
}
|
|
191
|
-
if (tempFileStream) {
|
|
192
|
-
tempFileStream.end();
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// Combine buffered chunks for truncation (already sanitized)
|
|
196
|
-
const fullOutput = outputChunks.join("");
|
|
197
|
-
const truncationResult = truncateTail(fullOutput);
|
|
198
|
-
|
|
199
|
-
// Handle timeout
|
|
200
|
-
if (timedOut) {
|
|
201
|
-
const timeoutSecs = Math.round((options?.timeout || 0) / 1000);
|
|
202
|
-
resolve({
|
|
203
|
-
output: `${fullOutput}\n\nCommand timed out after ${timeoutSecs} seconds`,
|
|
204
|
-
exitCode: undefined,
|
|
205
|
-
cancelled: true,
|
|
206
|
-
truncated: truncationResult.truncated,
|
|
207
|
-
fullOutputPath: tempFilePath,
|
|
208
|
-
});
|
|
209
|
-
return;
|
|
161
|
+
while (true) {
|
|
162
|
+
const { done, value } = await reader.read();
|
|
163
|
+
if (done) break;
|
|
164
|
+
await writer.write(value);
|
|
210
165
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
const cancelled = exitCode === null || (exitCode !== 0 && (options?.signal?.aborted ?? false));
|
|
214
|
-
|
|
215
|
-
resolve({
|
|
216
|
-
output: truncationResult.truncated ? truncationResult.content : fullOutput,
|
|
217
|
-
exitCode: cancelled ? undefined : exitCode,
|
|
218
|
-
cancelled,
|
|
219
|
-
truncated: truncationResult.truncated,
|
|
220
|
-
fullOutputPath: tempFilePath,
|
|
221
|
-
});
|
|
222
|
-
} catch (err) {
|
|
223
|
-
// Clean up
|
|
224
|
-
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
225
|
-
if (options?.signal) {
|
|
226
|
-
options.signal.removeEventListener("abort", abortHandler);
|
|
227
|
-
}
|
|
228
|
-
if (tempFileStream) {
|
|
229
|
-
tempFileStream.end();
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
reject(err);
|
|
166
|
+
} finally {
|
|
167
|
+
reader.releaseLock();
|
|
233
168
|
}
|
|
234
|
-
}
|
|
235
|
-
|
|
169
|
+
}
|
|
170
|
+
await Promise.all([
|
|
171
|
+
pumpStream(child.stdout as ReadableStream<Uint8Array>),
|
|
172
|
+
pumpStream(child.stderr as ReadableStream<Uint8Array>),
|
|
173
|
+
]);
|
|
174
|
+
} finally {
|
|
175
|
+
await writer.close();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Non-zero exit codes or signal-killed processes are considered cancelled if killed via signal
|
|
179
|
+
const exitCode = await child.exited;
|
|
180
|
+
|
|
181
|
+
const cancelled = exitCode === null || (exitCode !== 0 && (options?.signal?.aborted ?? false));
|
|
182
|
+
|
|
183
|
+
if (signal.timedOut()) {
|
|
184
|
+
const secs = Math.round(options!.timeout! / 1000);
|
|
185
|
+
return {
|
|
186
|
+
exitCode: undefined,
|
|
187
|
+
cancelled: true,
|
|
188
|
+
...sink.dump(`Command timed out after ${secs} seconds`),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
exitCode: cancelled ? undefined : exitCode,
|
|
194
|
+
cancelled,
|
|
195
|
+
...sink.dump(),
|
|
196
|
+
};
|
|
236
197
|
}
|
package/src/core/index.ts
CHANGED
|
@@ -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,108 @@ 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
|
+
const header = JSON.parse(sub.toString("utf8", 0, eol + 1));
|
|
522
|
+
// Validate session header
|
|
523
|
+
if (header.type !== "session" || typeof header.id !== "string") return null;
|
|
524
|
+
return header;
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
return readdirSync(sessionDir)
|
|
528
|
+
.map((f) => {
|
|
529
|
+
try {
|
|
530
|
+
if (!f.endsWith(".jsonl")) return null;
|
|
531
|
+
const path = join(sessionDir, f);
|
|
532
|
+
const fd = openSync(path, "r");
|
|
533
|
+
try {
|
|
534
|
+
const header = readHeader(fd);
|
|
535
|
+
if (!header) return null;
|
|
536
|
+
const mtime = statSync(path).mtimeMs;
|
|
537
|
+
return new RecentSessionInfo(path, mtime, header);
|
|
538
|
+
} finally {
|
|
539
|
+
closeSync(fd);
|
|
540
|
+
}
|
|
541
|
+
} catch {
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
})
|
|
545
|
+
.filter((x) => x !== null)
|
|
546
|
+
.sort((a, b) => b.mtime - a.mtime); // Sort newest first
|
|
480
547
|
} catch {
|
|
481
|
-
return
|
|
548
|
+
return [];
|
|
482
549
|
}
|
|
483
550
|
}
|
|
484
551
|
|
|
485
|
-
/**
|
|
486
|
-
export
|
|
487
|
-
|
|
488
|
-
path
|
|
489
|
-
timeAgo: string;
|
|
552
|
+
/** Exported for testing */
|
|
553
|
+
export function findMostRecentSession(sessionDir: string): string | null {
|
|
554
|
+
const sessions = getSortedSessions(sessionDir);
|
|
555
|
+
return sessions[0]?.path || null;
|
|
490
556
|
}
|
|
491
557
|
|
|
492
558
|
/** Format a time difference as a human-readable string */
|
|
@@ -506,41 +572,7 @@ function formatTimeAgo(date: Date): string {
|
|
|
506
572
|
|
|
507
573
|
/** Get recent sessions for display in welcome screen */
|
|
508
574
|
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
|
-
}
|
|
575
|
+
return getSortedSessions(sessionDir).slice(0, limit);
|
|
544
576
|
}
|
|
545
577
|
|
|
546
578
|
/**
|
|
@@ -588,7 +620,7 @@ export class SessionManager {
|
|
|
588
620
|
if (existsSync(this.sessionFile)) {
|
|
589
621
|
this.fileEntries = loadEntriesFromFile(this.sessionFile);
|
|
590
622
|
const header = this.fileEntries.find((e) => e.type === "session") as SessionHeader | undefined;
|
|
591
|
-
this.sessionId = header?.id ??
|
|
623
|
+
this.sessionId = header?.id ?? nanoid();
|
|
592
624
|
this.sessionTitle = header?.title;
|
|
593
625
|
|
|
594
626
|
if (migrateToCurrentVersion(this.fileEntries)) {
|
|
@@ -603,7 +635,7 @@ export class SessionManager {
|
|
|
603
635
|
}
|
|
604
636
|
|
|
605
637
|
newSession(options?: NewSessionOptions): string | undefined {
|
|
606
|
-
this.sessionId =
|
|
638
|
+
this.sessionId = nanoid();
|
|
607
639
|
const timestamp = new Date().toISOString();
|
|
608
640
|
const header: SessionHeader = {
|
|
609
641
|
type: "session",
|
|
@@ -676,16 +708,23 @@ export class SessionManager {
|
|
|
676
708
|
|
|
677
709
|
setSessionTitle(title: string): void {
|
|
678
710
|
this.sessionTitle = title;
|
|
679
|
-
|
|
711
|
+
|
|
712
|
+
// Update the in-memory header (so first flush includes title)
|
|
713
|
+
const header = this.fileEntries.find((e) => e.type === "session") as SessionHeader | undefined;
|
|
714
|
+
if (header) {
|
|
715
|
+
header.title = title;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Update the session file header with the title (if already flushed)
|
|
680
719
|
if (this.persist && this.sessionFile && existsSync(this.sessionFile)) {
|
|
681
720
|
try {
|
|
682
721
|
const content = readFileSync(this.sessionFile, "utf-8");
|
|
683
722
|
const lines = content.split("\n");
|
|
684
723
|
if (lines.length > 0) {
|
|
685
|
-
const
|
|
686
|
-
if (
|
|
687
|
-
|
|
688
|
-
lines[0] = JSON.stringify(
|
|
724
|
+
const fileHeader = JSON.parse(lines[0]) as SessionHeader;
|
|
725
|
+
if (fileHeader.type === "session") {
|
|
726
|
+
fileHeader.title = title;
|
|
727
|
+
lines[0] = JSON.stringify(fileHeader);
|
|
689
728
|
writeFileSync(this.sessionFile, lines.join("\n"));
|
|
690
729
|
}
|
|
691
730
|
}
|
|
@@ -1081,7 +1120,7 @@ export class SessionManager {
|
|
|
1081
1120
|
// Filter out LabelEntry from path - we'll recreate them from the resolved map
|
|
1082
1121
|
const pathWithoutLabels = path.filter((e) => e.type !== "label");
|
|
1083
1122
|
|
|
1084
|
-
const newSessionId =
|
|
1123
|
+
const newSessionId = nanoid();
|
|
1085
1124
|
const timestamp = new Date().toISOString();
|
|
1086
1125
|
const fileTimestamp = timestamp.replace(/[:.]/g, "-");
|
|
1087
1126
|
const newSessionFile = join(this.getSessionDir(), `${fileTimestamp}_${newSessionId}.jsonl`);
|