@oh-my-pi/pi-coding-agent 14.2.1 → 14.3.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 +59 -0
- package/package.json +19 -19
- package/src/cli/args.ts +10 -1
- package/src/cli/shell-cli.ts +15 -3
- package/src/config/settings-schema.ts +60 -1
- package/src/debug/system-info.ts +6 -2
- package/src/discovery/claude.ts +58 -36
- package/src/discovery/opencode.ts +20 -2
- package/src/edit/index.ts +2 -1
- package/src/edit/modes/chunk.ts +132 -56
- package/src/edit/modes/hashline.ts +36 -11
- package/src/edit/renderer.ts +98 -133
- package/src/edit/streaming.ts +351 -0
- package/src/exec/bash-executor.ts +60 -5
- package/src/internal-urls/docs-index.generated.ts +5 -5
- package/src/internal-urls/pi-protocol.ts +0 -2
- package/src/lsp/client.ts +8 -1
- package/src/lsp/defaults.json +2 -1
- package/src/modes/acp/acp-agent.ts +76 -2
- package/src/modes/components/assistant-message.ts +1 -34
- package/src/modes/components/hook-editor.ts +1 -1
- package/src/modes/components/tool-execution.ts +111 -101
- package/src/modes/controllers/input-controller.ts +1 -1
- package/src/modes/interactive-mode.ts +0 -2
- package/src/modes/theme/mermaid-cache.ts +13 -52
- package/src/modes/theme/theme.ts +2 -2
- package/src/prompts/system/system-prompt.md +1 -1
- package/src/prompts/tools/browser.md +1 -0
- package/src/prompts/tools/chunk-edit.md +25 -22
- package/src/prompts/tools/gh-pr-push.md +2 -1
- package/src/prompts/tools/grep.md +4 -3
- package/src/prompts/tools/lsp.md +6 -0
- package/src/prompts/tools/read-chunk.md +46 -7
- package/src/prompts/tools/read.md +7 -4
- package/src/sdk.ts +8 -5
- package/src/session/agent-session.ts +36 -20
- package/src/session/session-manager.ts +228 -57
- package/src/session/streaming-output.ts +11 -0
- package/src/system-prompt.ts +7 -2
- package/src/task/executor.ts +1 -0
- package/src/tools/bash.ts +13 -0
- package/src/tools/gh.ts +6 -16
- package/src/tools/sqlite-reader.ts +116 -3
- package/src/web/search/providers/codex.ts +129 -6
|
@@ -1264,74 +1264,245 @@ function extractTextFromContent(content: Message["content"]): string {
|
|
|
1264
1264
|
.join(" ");
|
|
1265
1265
|
}
|
|
1266
1266
|
|
|
1267
|
-
|
|
1268
|
-
|
|
1267
|
+
const SESSION_LIST_PREFIX_BYTES = 1024;
|
|
1268
|
+
const SESSION_LIST_PARALLEL_THRESHOLD = 64;
|
|
1269
|
+
const SESSION_LIST_MAX_WORKERS = 16;
|
|
1270
|
+
const sessionListPrefixDecoder = new TextDecoder("utf-8", { fatal: false });
|
|
1269
1271
|
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1272
|
+
async function readSessionListPrefix(file: string, storage: SessionStorage, buffer: Buffer): Promise<string> {
|
|
1273
|
+
if (!(storage instanceof FileSessionStorage)) {
|
|
1274
|
+
return storage.readTextPrefix(file, buffer.byteLength);
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
const handle = await fs.promises.open(file, "r");
|
|
1278
|
+
try {
|
|
1279
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, 0);
|
|
1280
|
+
return sessionListPrefixDecoder.decode(buffer.subarray(0, bytesRead));
|
|
1281
|
+
} finally {
|
|
1282
|
+
await handle.close();
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
function decodeJsonStringFragment(value: string): string {
|
|
1287
|
+
const safeValue = value.endsWith("\\") ? value.slice(0, -1) : value;
|
|
1288
|
+
try {
|
|
1289
|
+
return JSON.parse(`"${safeValue}"`) as string;
|
|
1290
|
+
} catch {
|
|
1291
|
+
return safeValue
|
|
1292
|
+
.replace(/\\n/g, "\n")
|
|
1293
|
+
.replace(/\\r/g, "\r")
|
|
1294
|
+
.replace(/\\t/g, "\t")
|
|
1295
|
+
.replace(/\\"/g, '"')
|
|
1296
|
+
.replace(/\\\\/g, "\\");
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1289
1299
|
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
let shortSummary: string | undefined;
|
|
1300
|
+
function extractStringProperty(source: string, name: string, startIndex = 0): string | undefined {
|
|
1301
|
+
const propertyIndex = source.indexOf(`"${name}"`, startIndex);
|
|
1302
|
+
if (propertyIndex === -1) return undefined;
|
|
1294
1303
|
|
|
1295
|
-
|
|
1296
|
-
|
|
1304
|
+
const colonIndex = source.indexOf(":", propertyIndex + name.length + 2);
|
|
1305
|
+
if (colonIndex === -1) return undefined;
|
|
1297
1306
|
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1307
|
+
let valueIndex = colonIndex + 1;
|
|
1308
|
+
while (valueIndex < source.length) {
|
|
1309
|
+
const char = source.charCodeAt(valueIndex);
|
|
1310
|
+
if (char !== 32 && char !== 9 && char !== 10 && char !== 13) break;
|
|
1311
|
+
valueIndex++;
|
|
1312
|
+
}
|
|
1313
|
+
if (source.charCodeAt(valueIndex) !== 34) return undefined;
|
|
1314
|
+
|
|
1315
|
+
const valueStart = valueIndex + 1;
|
|
1316
|
+
let escaped = false;
|
|
1317
|
+
for (let i = valueStart; i < source.length; i++) {
|
|
1318
|
+
const char = source.charCodeAt(i);
|
|
1319
|
+
if (escaped) {
|
|
1320
|
+
escaped = false;
|
|
1321
|
+
continue;
|
|
1322
|
+
}
|
|
1323
|
+
if (char === 92) {
|
|
1324
|
+
escaped = true;
|
|
1325
|
+
continue;
|
|
1326
|
+
}
|
|
1327
|
+
if (char === 34) {
|
|
1328
|
+
return decodeJsonStringFragment(source.slice(valueStart, i));
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
return decodeJsonStringFragment(source.slice(valueStart));
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
function countMessageMarkers(content: string): number {
|
|
1336
|
+
let count = 0;
|
|
1337
|
+
let index = 0;
|
|
1338
|
+
while (index < content.length) {
|
|
1339
|
+
const typeIndex = content.indexOf('"type"', index);
|
|
1340
|
+
if (typeIndex === -1) break;
|
|
1341
|
+
const colonIndex = content.indexOf(":", typeIndex + 6);
|
|
1342
|
+
if (colonIndex === -1) break;
|
|
1343
|
+
const type = extractStringProperty(content, "type", typeIndex);
|
|
1344
|
+
if (type === "message") count++;
|
|
1345
|
+
index = colonIndex + 1;
|
|
1346
|
+
}
|
|
1347
|
+
return count;
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
function extractFirstUserMessageFromPrefix(content: string): string | undefined {
|
|
1351
|
+
const roleIndex = content.indexOf('"role"');
|
|
1352
|
+
if (roleIndex === -1) return undefined;
|
|
1353
|
+
|
|
1354
|
+
let index = roleIndex;
|
|
1355
|
+
while (index !== -1) {
|
|
1356
|
+
const role = extractStringProperty(content, "role", index);
|
|
1357
|
+
if (role === "user") {
|
|
1358
|
+
return extractStringProperty(content, "content", index) ?? extractStringProperty(content, "text", index);
|
|
1359
|
+
}
|
|
1360
|
+
index = content.indexOf('"role"', index + 6);
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
return undefined;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
interface SessionListHeader {
|
|
1367
|
+
type: "session";
|
|
1368
|
+
id: string;
|
|
1369
|
+
cwd?: string;
|
|
1370
|
+
title?: string;
|
|
1371
|
+
parentSession?: string;
|
|
1372
|
+
timestamp?: string;
|
|
1373
|
+
}
|
|
1301
1374
|
|
|
1302
|
-
|
|
1303
|
-
|
|
1375
|
+
function parseSessionListHeader(
|
|
1376
|
+
content: string,
|
|
1377
|
+
entries: Array<Record<string, unknown>>,
|
|
1378
|
+
): SessionListHeader | undefined {
|
|
1379
|
+
const parsedHeader = entries[0];
|
|
1380
|
+
if (parsedHeader?.type === "session" && typeof parsedHeader.id === "string") {
|
|
1381
|
+
return {
|
|
1382
|
+
type: "session",
|
|
1383
|
+
id: parsedHeader.id,
|
|
1384
|
+
cwd: typeof parsedHeader.cwd === "string" ? parsedHeader.cwd : undefined,
|
|
1385
|
+
title: typeof parsedHeader.title === "string" ? parsedHeader.title : undefined,
|
|
1386
|
+
parentSession: typeof parsedHeader.parentSession === "string" ? parsedHeader.parentSession : undefined,
|
|
1387
|
+
timestamp: typeof parsedHeader.timestamp === "string" ? parsedHeader.timestamp : undefined,
|
|
1388
|
+
};
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
const firstLineEnd = content.indexOf("\n");
|
|
1392
|
+
const firstLine = firstLineEnd === -1 ? content : content.slice(0, firstLineEnd);
|
|
1393
|
+
if (extractStringProperty(firstLine, "type") !== "session") return undefined;
|
|
1394
|
+
|
|
1395
|
+
const id = extractStringProperty(firstLine, "id");
|
|
1396
|
+
if (!id) return undefined;
|
|
1397
|
+
|
|
1398
|
+
return {
|
|
1399
|
+
type: "session",
|
|
1400
|
+
id,
|
|
1401
|
+
cwd: extractStringProperty(firstLine, "cwd"),
|
|
1402
|
+
title: extractStringProperty(firstLine, "title"),
|
|
1403
|
+
parentSession: extractStringProperty(firstLine, "parentSession"),
|
|
1404
|
+
timestamp: extractStringProperty(firstLine, "timestamp"),
|
|
1405
|
+
};
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
function getSessionListWorkerCount(fileCount: number): number {
|
|
1409
|
+
if (fileCount <= SESSION_LIST_PARALLEL_THRESHOLD) return 1;
|
|
1410
|
+
return Math.min(
|
|
1411
|
+
SESSION_LIST_MAX_WORKERS,
|
|
1412
|
+
os.availableParallelism(),
|
|
1413
|
+
Math.ceil(fileCount / SESSION_LIST_PARALLEL_THRESHOLD),
|
|
1414
|
+
);
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
async function collectSessionFromFile(
|
|
1418
|
+
file: string,
|
|
1419
|
+
storage: SessionStorage,
|
|
1420
|
+
buffer: Buffer,
|
|
1421
|
+
): Promise<SessionInfo | undefined> {
|
|
1422
|
+
try {
|
|
1423
|
+
const content = await readSessionListPrefix(file, storage, buffer);
|
|
1424
|
+
const entries = parseJsonlLenient<Record<string, unknown>>(content);
|
|
1425
|
+
const header = parseSessionListHeader(content, entries);
|
|
1426
|
+
if (!header) return undefined;
|
|
1427
|
+
|
|
1428
|
+
let parsedMessageCount = 0;
|
|
1429
|
+
let firstMessage = "";
|
|
1430
|
+
const allMessages: string[] = [];
|
|
1431
|
+
let shortSummary: string | undefined;
|
|
1432
|
+
|
|
1433
|
+
for (let i = 1; i < entries.length; i++) {
|
|
1434
|
+
const entry = entries[i] as { type?: string; message?: Message; shortSummary?: string };
|
|
1435
|
+
|
|
1436
|
+
if (entry.type === "compaction" && typeof entry.shortSummary === "string") {
|
|
1437
|
+
shortSummary = entry.shortSummary;
|
|
1438
|
+
}
|
|
1304
1439
|
|
|
1305
|
-
|
|
1306
|
-
|
|
1440
|
+
if (entry.type === "message" && entry.message) {
|
|
1441
|
+
parsedMessageCount++;
|
|
1307
1442
|
|
|
1308
|
-
|
|
1309
|
-
|
|
1443
|
+
if (entry.message.role === "user" || entry.message.role === "assistant") {
|
|
1444
|
+
const textContent = extractTextFromContent(entry.message.content);
|
|
1310
1445
|
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1446
|
+
if (textContent) {
|
|
1447
|
+
allMessages.push(textContent);
|
|
1448
|
+
|
|
1449
|
+
if (!firstMessage && entry.message.role === "user") {
|
|
1450
|
+
firstMessage = textContent;
|
|
1315
1451
|
}
|
|
1316
1452
|
}
|
|
1317
1453
|
}
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1318
1456
|
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
}
|
|
1334
|
-
|
|
1457
|
+
firstMessage ||= extractFirstUserMessageFromPrefix(content) ?? "";
|
|
1458
|
+
const messageCount = Math.max(parsedMessageCount, countMessageMarkers(content));
|
|
1459
|
+
const stats = storage.statSync(file);
|
|
1460
|
+
return {
|
|
1461
|
+
path: file,
|
|
1462
|
+
id: header.id,
|
|
1463
|
+
cwd: header.cwd ?? "",
|
|
1464
|
+
title: header.title ?? shortSummary,
|
|
1465
|
+
parentSessionPath: header.parentSession,
|
|
1466
|
+
created: new Date(header.timestamp ?? ""),
|
|
1467
|
+
modified: stats.mtime,
|
|
1468
|
+
messageCount,
|
|
1469
|
+
firstMessage: firstMessage || "(no messages)",
|
|
1470
|
+
allMessagesText: allMessages.length > 0 ? allMessages.join(" ") : firstMessage,
|
|
1471
|
+
};
|
|
1472
|
+
} catch {
|
|
1473
|
+
return undefined;
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
async function collectSessionsFromFileStride(
|
|
1478
|
+
files: string[],
|
|
1479
|
+
storage: SessionStorage,
|
|
1480
|
+
startIndex: number,
|
|
1481
|
+
stride: number,
|
|
1482
|
+
): Promise<SessionInfo[]> {
|
|
1483
|
+
const sessions: SessionInfo[] = [];
|
|
1484
|
+
const buffer = Buffer.allocUnsafe(SESSION_LIST_PREFIX_BYTES);
|
|
1485
|
+
|
|
1486
|
+
for (let i = startIndex; i < files.length; i += stride) {
|
|
1487
|
+
const session = await collectSessionFromFile(files[i], storage, buffer);
|
|
1488
|
+
if (session) sessions.push(session);
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
return sessions;
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
async function collectSessionsFromFiles(files: string[], storage: SessionStorage): Promise<SessionInfo[]> {
|
|
1495
|
+
const workerCount = getSessionListWorkerCount(files.length);
|
|
1496
|
+
const sessions =
|
|
1497
|
+
workerCount === 1
|
|
1498
|
+
? await collectSessionsFromFileStride(files, storage, 0, 1)
|
|
1499
|
+
: (
|
|
1500
|
+
await Promise.all(
|
|
1501
|
+
Array.from({ length: workerCount }, (_, workerIndex) =>
|
|
1502
|
+
collectSessionsFromFileStride(files, storage, workerIndex, workerCount),
|
|
1503
|
+
),
|
|
1504
|
+
)
|
|
1505
|
+
).flat();
|
|
1335
1506
|
|
|
1336
1507
|
sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
|
|
1337
1508
|
return sessions;
|
|
@@ -2771,7 +2942,7 @@ export class SessionManager {
|
|
|
2771
2942
|
static async listAll(storage: SessionStorage = new FileSessionStorage()): Promise<SessionInfo[]> {
|
|
2772
2943
|
const sessionsRoot = path.join(getDefaultAgentDir(), "sessions");
|
|
2773
2944
|
try {
|
|
2774
|
-
const files = Array.
|
|
2945
|
+
const files = await Array.fromAsync(new Bun.Glob("*/*.jsonl").scan(sessionsRoot), name =>
|
|
2775
2946
|
path.join(sessionsRoot, name),
|
|
2776
2947
|
);
|
|
2777
2948
|
return await collectSessionsFromFiles(files, storage);
|
|
@@ -680,6 +680,17 @@ export class OutputSink {
|
|
|
680
680
|
});
|
|
681
681
|
}
|
|
682
682
|
|
|
683
|
+
/**
|
|
684
|
+
* Replace the in-memory buffer with the given text while preserving the
|
|
685
|
+
* streaming counters (totalLines/totalBytes reflect the raw chunks that
|
|
686
|
+
* already reached the sink). Used when an upstream minimizer rewrites the
|
|
687
|
+
* captured output after the raw bytes have already been streamed.
|
|
688
|
+
*/
|
|
689
|
+
replace(text: string): void {
|
|
690
|
+
this.#buffer = text;
|
|
691
|
+
this.#bufferBytes = Buffer.byteLength(text, "utf-8");
|
|
692
|
+
}
|
|
693
|
+
|
|
683
694
|
async dump(notice?: string): Promise<OutputSummary> {
|
|
684
695
|
const noticeLine = notice ? `[${notice}]\n` : "";
|
|
685
696
|
const outputLines = this.#buffer.length > 0 ? countNewlines(this.#buffer) + 1 : 0;
|
package/src/system-prompt.ts
CHANGED
|
@@ -275,13 +275,18 @@ async function getCachedGpu(): Promise<string | undefined> {
|
|
|
275
275
|
}
|
|
276
276
|
async function getEnvironmentInfo(): Promise<Array<{ label: string; value: string }>> {
|
|
277
277
|
const gpu = await getCachedGpu();
|
|
278
|
-
|
|
278
|
+
let cpuModel: string | undefined;
|
|
279
|
+
try {
|
|
280
|
+
cpuModel = os.cpus()[0]?.model;
|
|
281
|
+
} catch {
|
|
282
|
+
cpuModel = undefined;
|
|
283
|
+
}
|
|
279
284
|
const entries: Array<{ label: string; value: string | undefined }> = [
|
|
280
285
|
{ label: "OS", value: `${os.platform()} ${os.release()}` },
|
|
281
286
|
{ label: "Distro", value: os.type() },
|
|
282
287
|
{ label: "Kernel", value: os.version() },
|
|
283
288
|
{ label: "Arch", value: os.arch() },
|
|
284
|
-
{ label: "CPU", value:
|
|
289
|
+
{ label: "CPU", value: cpuModel },
|
|
285
290
|
{ label: "GPU", value: gpu },
|
|
286
291
|
{ label: "Terminal", value: getTerminalName() },
|
|
287
292
|
];
|
package/src/task/executor.ts
CHANGED
|
@@ -975,6 +975,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
975
975
|
enableLsp: lspEnabled,
|
|
976
976
|
skipPythonPreflight,
|
|
977
977
|
enableMCP,
|
|
978
|
+
mcpManager: options.mcpManager,
|
|
978
979
|
customTools: mcpProxyTools.length > 0 ? mcpProxyTools : undefined,
|
|
979
980
|
});
|
|
980
981
|
|
package/src/tools/bash.ts
CHANGED
|
@@ -30,6 +30,17 @@ export const BASH_DEFAULT_PREVIEW_LINES = 10;
|
|
|
30
30
|
const BASH_ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
31
31
|
const DEFAULT_AUTO_BACKGROUND_THRESHOLD_MS = 60_000;
|
|
32
32
|
|
|
33
|
+
async function saveBashOriginalArtifact(session: ToolSession, originalText: string): Promise<string | undefined> {
|
|
34
|
+
try {
|
|
35
|
+
const alloc = await session.allocateOutputArtifact?.("bash-original");
|
|
36
|
+
if (!alloc?.path || !alloc.id) return undefined;
|
|
37
|
+
await Bun.write(alloc.path, originalText);
|
|
38
|
+
return alloc.id;
|
|
39
|
+
} catch {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
33
44
|
const bashSchemaBase = Type.Object({
|
|
34
45
|
command: Type.String({ description: "Command to execute" }),
|
|
35
46
|
env: Type.Optional(
|
|
@@ -390,6 +401,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
390
401
|
latestText = tailBuffer.text();
|
|
391
402
|
void reportProgress(latestText, { async: { state: "running", jobId, type: "bash" } });
|
|
392
403
|
},
|
|
404
|
+
onMinimizedSave: originalText => saveBashOriginalArtifact(this.session, originalText),
|
|
393
405
|
});
|
|
394
406
|
const finalResult = this.#buildCompletedResult(
|
|
395
407
|
result,
|
|
@@ -656,6 +668,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
656
668
|
artifactPath,
|
|
657
669
|
artifactId,
|
|
658
670
|
onChunk: streamTailUpdates(tailBuffer, onUpdate),
|
|
671
|
+
onMinimizedSave: originalText => saveBashOriginalArtifact(this.session, originalText),
|
|
659
672
|
});
|
|
660
673
|
if (result.cancelled) {
|
|
661
674
|
if (signal?.aborted) {
|
package/src/tools/gh.ts
CHANGED
|
@@ -611,14 +611,6 @@ function toLocalBranchRef(value: string): string {
|
|
|
611
611
|
return `refs/heads/${value}`;
|
|
612
612
|
}
|
|
613
613
|
|
|
614
|
-
function stripHeadsRef(value: string | undefined): string | undefined {
|
|
615
|
-
if (!value) {
|
|
616
|
-
return undefined;
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
return value.startsWith("refs/heads/") ? value.slice("refs/heads/".length) : value;
|
|
620
|
-
}
|
|
621
|
-
|
|
622
614
|
async function requireGitRepoRoot(cwd: string, signal?: AbortSignal): Promise<string> {
|
|
623
615
|
const repoRoot = await git.repo.root(cwd, signal);
|
|
624
616
|
if (!repoRoot) {
|
|
@@ -763,10 +755,13 @@ async function resolvePrBranchPushTarget(
|
|
|
763
755
|
maintainerCanModify?: boolean;
|
|
764
756
|
isCrossRepository: boolean;
|
|
765
757
|
}> {
|
|
758
|
+
const headRef = await git.config.getBranch(repoRoot, localBranch, "ompPrHeadRef", signal);
|
|
759
|
+
if (!headRef) {
|
|
760
|
+
throw new ToolError(`branch ${localBranch} has no PR push metadata; check it out via gh_pr_checkout first`);
|
|
761
|
+
}
|
|
762
|
+
|
|
766
763
|
const pushRemote = await git.config.getBranch(repoRoot, localBranch, "pushRemote", signal);
|
|
767
764
|
const remote = await git.config.getBranch(repoRoot, localBranch, "remote", signal);
|
|
768
|
-
const mergeRef = await git.config.getBranch(repoRoot, localBranch, "merge", signal);
|
|
769
|
-
const headRef = await git.config.getBranch(repoRoot, localBranch, "ompPrHeadRef", signal);
|
|
770
765
|
const prUrl = await git.config.getBranch(repoRoot, localBranch, "ompPrUrl", signal);
|
|
771
766
|
const maintainerCanModifyValue = await git.config.getBranch(
|
|
772
767
|
repoRoot,
|
|
@@ -781,14 +776,9 @@ async function resolvePrBranchPushTarget(
|
|
|
781
776
|
throw new ToolError(`branch ${localBranch} has no configured push remote`);
|
|
782
777
|
}
|
|
783
778
|
|
|
784
|
-
const remoteBranch = headRef ?? stripHeadsRef(mergeRef);
|
|
785
|
-
if (!remoteBranch) {
|
|
786
|
-
throw new ToolError(`branch ${localBranch} has no tracked PR head ref`);
|
|
787
|
-
}
|
|
788
|
-
|
|
789
779
|
return {
|
|
790
780
|
remoteName,
|
|
791
|
-
remoteBranch,
|
|
781
|
+
remoteBranch: headRef,
|
|
792
782
|
remoteUrl: await git.remote.url(repoRoot, remoteName, signal),
|
|
793
783
|
prUrl,
|
|
794
784
|
maintainerCanModify:
|
|
@@ -252,6 +252,112 @@ function resolveOrderClause(order: string | undefined, columns: string[]): strin
|
|
|
252
252
|
return ` ORDER BY ${quoteSqliteIdentifier(column)} ${direction.toUpperCase()}`;
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
+
const FORBIDDEN_WHERE_KEYWORDS = new Set([
|
|
256
|
+
"limit",
|
|
257
|
+
"offset",
|
|
258
|
+
"union",
|
|
259
|
+
"intersect",
|
|
260
|
+
"except",
|
|
261
|
+
"attach",
|
|
262
|
+
"detach",
|
|
263
|
+
"pragma",
|
|
264
|
+
]);
|
|
265
|
+
|
|
266
|
+
const COMMENT_OR_TERMINATOR_ERROR =
|
|
267
|
+
"SQLite 'where' clause must not contain comments or statement terminators; use '?q=SELECT ...' for raw SQL";
|
|
268
|
+
const FORBIDDEN_KEYWORD_ERROR =
|
|
269
|
+
"SQLite 'where' clause must not contain LIMIT/OFFSET/UNION/INTERSECT/EXCEPT/ATTACH/DETACH/PRAGMA; use '?q=SELECT ...' for raw SQL";
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Scans a `where=` clause character-by-character, tracking single- and double-quoted
|
|
273
|
+
* string literals, and rejects SQL control syntax that would otherwise let the
|
|
274
|
+
* structured helper path escape the bound `LIMIT ? OFFSET ?` pagination:
|
|
275
|
+
*
|
|
276
|
+
* - comments (`--`, `/* ... *\/`) and statement terminators (`;`) outside quotes
|
|
277
|
+
* - pagination / attach / pragma keywords outside quotes
|
|
278
|
+
*
|
|
279
|
+
* Raw SQL remains available through `?q=SELECT ...`.
|
|
280
|
+
*/
|
|
281
|
+
function findWhereClauseViolation(sql: string): string | null {
|
|
282
|
+
let inSingleQuote = false;
|
|
283
|
+
let inDoubleQuote = false;
|
|
284
|
+
let tokenStart = -1;
|
|
285
|
+
let keywordViolation: string | null = null;
|
|
286
|
+
|
|
287
|
+
const flushToken = (end: number): void => {
|
|
288
|
+
if (tokenStart < 0 || keywordViolation) {
|
|
289
|
+
tokenStart = -1;
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const token = sql.slice(tokenStart, end).toLowerCase();
|
|
293
|
+
tokenStart = -1;
|
|
294
|
+
if (FORBIDDEN_WHERE_KEYWORDS.has(token)) {
|
|
295
|
+
keywordViolation = FORBIDDEN_KEYWORD_ERROR;
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
for (let index = 0; index <= sql.length; index++) {
|
|
300
|
+
const char = index < sql.length ? sql[index] : undefined;
|
|
301
|
+
const next = index + 1 < sql.length ? sql[index + 1] : undefined;
|
|
302
|
+
|
|
303
|
+
if (inSingleQuote) {
|
|
304
|
+
if (char === "'" && next === "'") {
|
|
305
|
+
index += 1;
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
if (char === "'") {
|
|
309
|
+
inSingleQuote = false;
|
|
310
|
+
}
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (inDoubleQuote) {
|
|
314
|
+
if (char === '"' && next === '"') {
|
|
315
|
+
index += 1;
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (char === '"') {
|
|
319
|
+
inDoubleQuote = false;
|
|
320
|
+
}
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const isIdent = char !== undefined && /[A-Za-z0-9_]/.test(char);
|
|
325
|
+
if (isIdent) {
|
|
326
|
+
if (tokenStart < 0) tokenStart = index;
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
flushToken(index);
|
|
331
|
+
|
|
332
|
+
if (char === undefined) break;
|
|
333
|
+
if (char === "'") {
|
|
334
|
+
inSingleQuote = true;
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
if (char === '"') {
|
|
338
|
+
inDoubleQuote = true;
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
if (char === ";") return COMMENT_OR_TERMINATOR_ERROR;
|
|
342
|
+
if ((char === "-" && next === "-") || (char === "/" && next === "*") || (char === "*" && next === "/")) {
|
|
343
|
+
return COMMENT_OR_TERMINATOR_ERROR;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return keywordViolation;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function validateWhereClause(where: string | undefined): string | undefined {
|
|
351
|
+
if (!where) return undefined;
|
|
352
|
+
const trimmed = where.trim();
|
|
353
|
+
if (!trimmed) return undefined;
|
|
354
|
+
const violation = findWhereClauseViolation(trimmed);
|
|
355
|
+
if (violation) {
|
|
356
|
+
throw new ToolError(violation);
|
|
357
|
+
}
|
|
358
|
+
return trimmed;
|
|
359
|
+
}
|
|
360
|
+
|
|
255
361
|
function normalizeWriteValue(value: unknown, column: string): SqliteBinding {
|
|
256
362
|
if (value === null) return null;
|
|
257
363
|
if (
|
|
@@ -360,7 +466,7 @@ export function parseSqliteSelector(subPath: string, queryString: string): Sqlit
|
|
|
360
466
|
return { kind: "row", table, key };
|
|
361
467
|
}
|
|
362
468
|
|
|
363
|
-
const where = params.get("where")
|
|
469
|
+
const where = validateWhereClause(params.get("where") ?? undefined);
|
|
364
470
|
const order = params.get("order")?.trim() || undefined;
|
|
365
471
|
const hasQueryParams = params.has("limit") || params.has("offset") || order !== undefined || where !== undefined;
|
|
366
472
|
if (hasQueryParams) {
|
|
@@ -448,12 +554,19 @@ export function queryRows(
|
|
|
448
554
|
opts: { limit: number; offset: number; order?: string; where?: string },
|
|
449
555
|
): { columns: string[]; rows: Record<string, unknown>[]; totalCount: number } {
|
|
450
556
|
const columns = getTableColumns(db, table);
|
|
451
|
-
const
|
|
557
|
+
const validatedWhere = validateWhereClause(opts.where);
|
|
558
|
+
const whereClause = validatedWhere ? ` WHERE ${validatedWhere}` : "";
|
|
452
559
|
const orderClause = resolveOrderClause(opts.order, columns);
|
|
453
560
|
const countSql = `SELECT COUNT(*) AS count FROM ${quoteSqliteIdentifier(table)}${whereClause}`;
|
|
454
561
|
const selectSql = `SELECT * FROM ${quoteSqliteIdentifier(table)}${whereClause}${orderClause} LIMIT ? OFFSET ?`;
|
|
455
562
|
const totalCount = db.prepare<SqliteCountRow, []>(countSql).get()?.count ?? 0;
|
|
456
|
-
const
|
|
563
|
+
const statement = db.prepare<SqliteRow, SQLQueryBindings[]>(selectSql);
|
|
564
|
+
if (statement.paramsCount !== 2) {
|
|
565
|
+
throw new ToolError(
|
|
566
|
+
"SQLite where clause changed the expected pagination parameters; use q=SELECT ... for raw SQL",
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
const rows = statement.all(opts.limit, opts.offset);
|
|
457
570
|
return { columns, rows, totalCount };
|
|
458
571
|
}
|
|
459
572
|
|