naiad-cli 0.2.38 → 0.2.39
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/extensions/naiad-extension.ts +334 -180
- package/package.json +2 -2
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
calculateCost,
|
|
12
12
|
} from "@mariozechner/pi-ai";
|
|
13
13
|
import { execSync, execFileSync, spawn } from "child_process";
|
|
14
|
-
|
|
14
|
+
// Note: readline removed - using manual LF buffering for JSONL framing (v0.57.0+ compatibility)
|
|
15
15
|
import * as fs from "fs";
|
|
16
16
|
import * as path from "path";
|
|
17
17
|
import * as os from "os";
|
|
@@ -637,10 +637,11 @@ When you're done, provide a clear, actionable summary the caller can act on.`;
|
|
|
637
637
|
let closed = false;
|
|
638
638
|
let terminationReason: "timeout" | "abort" | null = null;
|
|
639
639
|
let statusLine = "";
|
|
640
|
+
let stdoutBuffer = "";
|
|
641
|
+
const stdoutDecoder = new TextDecoder("utf-8", { fatal: false });
|
|
642
|
+
const stderrDecoder = new TextDecoder("utf-8", { fatal: false });
|
|
640
643
|
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
rl.on("line", (line: string) => {
|
|
644
|
+
function processLine(line: string) {
|
|
644
645
|
if (!line.trim()) return;
|
|
645
646
|
let event: any;
|
|
646
647
|
try {
|
|
@@ -690,10 +691,40 @@ When you're done, provide a clear, actionable summary the caller can act on.`;
|
|
|
690
691
|
}
|
|
691
692
|
}
|
|
692
693
|
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function flushStdoutBuffer(final = false) {
|
|
697
|
+
if (final && stdoutBuffer.trim()) {
|
|
698
|
+
processLine(stdoutBuffer.trim());
|
|
699
|
+
stdoutBuffer = "";
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// Use LF-only buffering for JSONL (v0.57.0+ compatibility)
|
|
704
|
+
// readline is avoided because it splits on Unicode line separators (U+2028/U+2029)
|
|
705
|
+
child.stdout!.on("data", (data: Buffer) => {
|
|
706
|
+
stdoutBuffer += stdoutDecoder.decode(data, { stream: true });
|
|
707
|
+
const lines = stdoutBuffer.split("\n");
|
|
708
|
+
stdoutBuffer = lines.pop() ?? "";
|
|
709
|
+
|
|
710
|
+
for (const line of lines) {
|
|
711
|
+
processLine(line);
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
child.stdout!.on("end", () => {
|
|
716
|
+
// Flush decoder and process any remaining partial line
|
|
717
|
+
stdoutBuffer += stdoutDecoder.decode(undefined, { stream: false });
|
|
718
|
+
flushStdoutBuffer(true);
|
|
693
719
|
});
|
|
694
720
|
|
|
695
721
|
child.stderr!.on("data", (data: Buffer) => {
|
|
696
|
-
stderr +=
|
|
722
|
+
stderr += stderrDecoder.decode(data, { stream: true });
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
child.stderr!.on("end", () => {
|
|
726
|
+
// Flush any remaining stderr bytes
|
|
727
|
+
stderr += stderrDecoder.decode(undefined, { stream: false });
|
|
697
728
|
});
|
|
698
729
|
|
|
699
730
|
child.on("error", (err) => {
|
|
@@ -719,6 +750,9 @@ When you're done, provide a clear, actionable summary the caller can act on.`;
|
|
|
719
750
|
signal.removeEventListener("abort", abortHandler);
|
|
720
751
|
}
|
|
721
752
|
|
|
753
|
+
// Flush any remaining output in the stdout buffer
|
|
754
|
+
flushStdoutBuffer(true);
|
|
755
|
+
|
|
722
756
|
if (terminationReason === "timeout") {
|
|
723
757
|
const timeoutSec = Math.round(timeoutMs / 1000);
|
|
724
758
|
reject(new Error(`Seer timed out after ${timeoutSec} seconds`));
|
|
@@ -1204,17 +1238,125 @@ When you're done, provide a clear, actionable summary the caller can act on.`;
|
|
|
1204
1238
|
const isGHA = process.env.GITHUB_ACTIONS === "true";
|
|
1205
1239
|
const apiBaseUrl = inferenceUrl.replace(/\/api\/v1\/inference$/, "");
|
|
1206
1240
|
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1241
|
+
const GITHUB_TOOL_TIMEOUT_MS = 30_000;
|
|
1242
|
+
|
|
1243
|
+
function createAbortError(message = "Aborted"): Error {
|
|
1244
|
+
const err = new Error(message);
|
|
1245
|
+
err.name = "AbortError";
|
|
1246
|
+
return err;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
function throwIfAborted(signal?: AbortSignal): void {
|
|
1250
|
+
if (signal?.aborted) {
|
|
1251
|
+
throw createAbortError();
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
function waitForTurn(previous: Promise<unknown>, signal?: AbortSignal): Promise<void> {
|
|
1256
|
+
if (!signal) {
|
|
1257
|
+
return previous.then(() => undefined);
|
|
1258
|
+
}
|
|
1259
|
+
if (signal.aborted) {
|
|
1260
|
+
return Promise.reject(createAbortError());
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
return new Promise<void>((resolve, reject) => {
|
|
1264
|
+
const onAbort = () => {
|
|
1265
|
+
signal.removeEventListener("abort", onAbort);
|
|
1266
|
+
reject(createAbortError());
|
|
1267
|
+
};
|
|
1268
|
+
|
|
1269
|
+
previous.then(
|
|
1270
|
+
() => {
|
|
1271
|
+
signal.removeEventListener("abort", onAbort);
|
|
1272
|
+
resolve();
|
|
1273
|
+
},
|
|
1274
|
+
(err) => {
|
|
1275
|
+
signal.removeEventListener("abort", onAbort);
|
|
1276
|
+
reject(err);
|
|
1277
|
+
},
|
|
1278
|
+
);
|
|
1279
|
+
|
|
1280
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1216
1281
|
});
|
|
1217
|
-
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
// Abort-aware mutex for serializing mutating GitHub operations.
|
|
1285
|
+
// If a caller aborts while waiting, its queued operation is skipped and
|
|
1286
|
+
// later operations can continue once earlier ones finish.
|
|
1287
|
+
class AbortAwareMutex {
|
|
1288
|
+
private tail: Promise<void> = Promise.resolve();
|
|
1289
|
+
|
|
1290
|
+
async acquire<T>(fn: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
1291
|
+
throwIfAborted(signal);
|
|
1292
|
+
|
|
1293
|
+
const previous = this.tail;
|
|
1294
|
+
let release!: () => void;
|
|
1295
|
+
const current = new Promise<void>((resolve) => {
|
|
1296
|
+
release = resolve;
|
|
1297
|
+
});
|
|
1298
|
+
|
|
1299
|
+
// Successors still wait for `previous`, but this slot can be released
|
|
1300
|
+
// early if the waiting caller aborts.
|
|
1301
|
+
this.tail = previous.then(() => current, () => current);
|
|
1302
|
+
|
|
1303
|
+
try {
|
|
1304
|
+
await waitForTurn(previous, signal);
|
|
1305
|
+
throwIfAborted(signal);
|
|
1306
|
+
return await fn();
|
|
1307
|
+
} finally {
|
|
1308
|
+
release();
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
const githubMutex = new AbortAwareMutex();
|
|
1313
|
+
|
|
1314
|
+
async function callToolEndpoint(
|
|
1315
|
+
method: string,
|
|
1316
|
+
endpoint: string,
|
|
1317
|
+
body?: unknown,
|
|
1318
|
+
signal?: AbortSignal,
|
|
1319
|
+
): Promise<any> {
|
|
1320
|
+
const url = `${apiBaseUrl}/api/v1/threads/${threadId}/github/${endpoint}`;
|
|
1321
|
+
const timeoutSignal = AbortSignal.timeout(GITHUB_TOOL_TIMEOUT_MS);
|
|
1322
|
+
const combinedSignal = signal
|
|
1323
|
+
? AbortSignal.any([signal, timeoutSignal])
|
|
1324
|
+
: timeoutSignal;
|
|
1325
|
+
|
|
1326
|
+
let res: Response;
|
|
1327
|
+
let raw = "";
|
|
1328
|
+
|
|
1329
|
+
try {
|
|
1330
|
+
res = await fetch(url, {
|
|
1331
|
+
method,
|
|
1332
|
+
headers: {
|
|
1333
|
+
"Content-Type": "application/json",
|
|
1334
|
+
Authorization: `Bearer ${apiKey}`,
|
|
1335
|
+
},
|
|
1336
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
1337
|
+
signal: combinedSignal,
|
|
1338
|
+
});
|
|
1339
|
+
|
|
1340
|
+
raw = await res.text();
|
|
1341
|
+
} catch (err) {
|
|
1342
|
+
if (signal?.aborted) throw err;
|
|
1343
|
+
if (timeoutSignal.aborted) {
|
|
1344
|
+
throw new Error(
|
|
1345
|
+
`GitHub tool request timed out after ${Math.round(GITHUB_TOOL_TIMEOUT_MS / 1000)} seconds`,
|
|
1346
|
+
);
|
|
1347
|
+
}
|
|
1348
|
+
throw err;
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
let data: any = {};
|
|
1352
|
+
if (raw) {
|
|
1353
|
+
try {
|
|
1354
|
+
data = JSON.parse(raw);
|
|
1355
|
+
} catch {
|
|
1356
|
+
data = { raw };
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1218
1360
|
if (res.status === 409) {
|
|
1219
1361
|
return data;
|
|
1220
1362
|
}
|
|
@@ -1237,7 +1379,7 @@ When you're done, provide a clear, actionable summary the caller can act on.`;
|
|
|
1237
1379
|
parameters: Type.Object({}),
|
|
1238
1380
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
1239
1381
|
if (isGHA) {
|
|
1240
|
-
const data = await callToolEndpoint("GET", "context");
|
|
1382
|
+
const data = await callToolEndpoint("GET", "context", undefined, signal);
|
|
1241
1383
|
return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
|
|
1242
1384
|
}
|
|
1243
1385
|
|
|
@@ -1300,122 +1442,125 @@ When you're done, provide a clear, actionable summary the caller can act on.`;
|
|
|
1300
1442
|
),
|
|
1301
1443
|
}),
|
|
1302
1444
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
1303
|
-
//
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
try {
|
|
1312
|
-
execFileSync("git", ["diff", "--cached", "--quiet"]);
|
|
1313
|
-
// exit 0 means no changes
|
|
1314
|
-
return { content: [{ type: "text" as const, text: JSON.stringify({ pushed: false, reason: "no_changes" }) }] };
|
|
1315
|
-
} catch {
|
|
1316
|
-
// exit non-zero means there are staged changes — continue
|
|
1317
|
-
}
|
|
1445
|
+
// Serialize mutating GitHub operations to prevent race conditions (v0.58.0+ parallel tool execution)
|
|
1446
|
+
return githubMutex.acquire(async () => {
|
|
1447
|
+
// Stage files
|
|
1448
|
+
if (params.files && params.files.length > 0) {
|
|
1449
|
+
execFileSync("git", ["add", ...params.files]);
|
|
1450
|
+
} else {
|
|
1451
|
+
execFileSync("git", ["add", "-A"]);
|
|
1452
|
+
}
|
|
1318
1453
|
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1454
|
+
// Check if there are changes to commit
|
|
1455
|
+
try {
|
|
1456
|
+
execFileSync("git", ["diff", "--cached", "--quiet"]);
|
|
1457
|
+
// exit 0 means no changes
|
|
1458
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ pushed: false, reason: "no_changes" }) }] };
|
|
1459
|
+
} catch {
|
|
1460
|
+
// exit non-zero means there are staged changes — continue
|
|
1461
|
+
}
|
|
1323
1462
|
|
|
1324
|
-
|
|
1463
|
+
// Set git identity and commit
|
|
1464
|
+
execFileSync("git", ["config", "user.name", "naiad-bot"]);
|
|
1465
|
+
execFileSync("git", ["config", "user.email", "266131081+naiad-bot@users.noreply.github.com"]);
|
|
1466
|
+
execFileSync("git", ["commit", "-m", params.message]);
|
|
1325
1467
|
|
|
1326
|
-
|
|
1327
|
-
// Step 1: Preflight — validate push safety and get expectedRef WITHOUT minting a push token
|
|
1328
|
-
const preflight = await callToolEndpoint("POST", "push-preflight", {
|
|
1329
|
-
message: params.message,
|
|
1330
|
-
commit_sha: headSha,
|
|
1331
|
-
});
|
|
1468
|
+
const headSha = execFileSync("git", ["rev-parse", "HEAD"]).toString().trim();
|
|
1332
1469
|
|
|
1333
|
-
if (
|
|
1334
|
-
|
|
1335
|
-
|
|
1470
|
+
if (isGHA) {
|
|
1471
|
+
// Step 1: Preflight — validate push safety and get expectedRef WITHOUT minting a push token
|
|
1472
|
+
const preflight = await callToolEndpoint("POST", "push-preflight", {
|
|
1473
|
+
message: params.message,
|
|
1474
|
+
commit_sha: headSha,
|
|
1475
|
+
}, signal);
|
|
1336
1476
|
|
|
1337
|
-
|
|
1477
|
+
if (preflight.error) {
|
|
1478
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(preflight) }] };
|
|
1479
|
+
}
|
|
1338
1480
|
|
|
1339
|
-
|
|
1340
|
-
const merges = execFileSync("git", ["rev-list", "--merges", `${expectedRef}..HEAD`]).toString().trim();
|
|
1341
|
-
if (merges) {
|
|
1342
|
-
return {
|
|
1343
|
-
content: [{ type: "text" as const, text: JSON.stringify({ error: "policy_denied", message: "Merge commits are not allowed", retryable: false }) }],
|
|
1344
|
-
};
|
|
1345
|
-
}
|
|
1481
|
+
const { expectedRef, branch: preflightBranch } = preflight;
|
|
1346
1482
|
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1483
|
+
// Step 2: Check for merge commits (pre-push safety) using server-provided expectedRef
|
|
1484
|
+
const merges = execFileSync("git", ["rev-list", "--merges", `${expectedRef}..HEAD`]).toString().trim();
|
|
1485
|
+
if (merges) {
|
|
1486
|
+
return {
|
|
1487
|
+
content: [{ type: "text" as const, text: JSON.stringify({ error: "policy_denied", message: "Merge commits are not allowed", retryable: false }) }],
|
|
1488
|
+
};
|
|
1489
|
+
}
|
|
1352
1490
|
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1491
|
+
// Step 3: Mint push token — only after merge check passes
|
|
1492
|
+
const pushData = await callToolEndpoint("POST", "commit-and-push", {
|
|
1493
|
+
message: params.message,
|
|
1494
|
+
commit_sha: headSha,
|
|
1495
|
+
}, signal);
|
|
1356
1496
|
|
|
1357
|
-
|
|
1497
|
+
if (pushData.error) {
|
|
1498
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(pushData) }] };
|
|
1499
|
+
}
|
|
1358
1500
|
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1501
|
+
const { pushToken, pushUrl, branch } = pushData;
|
|
1502
|
+
|
|
1503
|
+
// Validate pushUrl matches the expected repo (not just any GitHub URL)
|
|
1504
|
+
const rawRemote = execFileSync("git", ["remote", "get-url", "origin"]).toString().trim();
|
|
1505
|
+
const expectedRepo = rawRemote
|
|
1506
|
+
.replace(/^https?:\/\/github\.com\//, "")
|
|
1507
|
+
.replace(/^git@github\.com:/, "")
|
|
1508
|
+
.replace(/\.git$/, "");
|
|
1509
|
+
const expectedPushUrl = `https://github.com/${expectedRepo}.git`;
|
|
1510
|
+
if (!pushUrl || pushUrl !== expectedPushUrl) {
|
|
1511
|
+
return {
|
|
1512
|
+
content: [{ type: "text" as const, text: JSON.stringify({ error: "policy_denied", message: "Push URL does not match expected repository", retryable: false }) }],
|
|
1513
|
+
};
|
|
1514
|
+
}
|
|
1371
1515
|
|
|
1372
|
-
|
|
1516
|
+
const basicAuth = Buffer.from(`x-access-token:${pushToken}`).toString("base64");
|
|
1373
1517
|
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1518
|
+
// Clean-room push — env vars (GIT_CONFIG_NOSYSTEM, GIT_CONFIG_GLOBAL,
|
|
1519
|
+
// GIT_CONFIG) already neutralize system/global/local config includes.
|
|
1520
|
+
try {
|
|
1521
|
+
execFileSync(
|
|
1522
|
+
"git",
|
|
1523
|
+
[
|
|
1524
|
+
"-c", "core.hooksPath=/dev/null",
|
|
1525
|
+
"-c", "credential.helper=",
|
|
1526
|
+
`-c`, `http.extraheader=Authorization: Basic ${basicAuth}`,
|
|
1527
|
+
"push", pushUrl, `HEAD:refs/heads/${branch}`, "--no-force",
|
|
1528
|
+
],
|
|
1529
|
+
{
|
|
1530
|
+
env: {
|
|
1531
|
+
PATH: process.env.PATH,
|
|
1532
|
+
HOME: process.env.HOME,
|
|
1533
|
+
GIT_CONFIG_NOSYSTEM: "1",
|
|
1534
|
+
GIT_CONFIG_GLOBAL: "/dev/null",
|
|
1535
|
+
GIT_CONFIG: "/dev/null",
|
|
1536
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
1537
|
+
},
|
|
1393
1538
|
},
|
|
1394
|
-
|
|
1395
|
-
)
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
}
|
|
1539
|
+
);
|
|
1540
|
+
} catch (pushErr: any) {
|
|
1541
|
+
// Sanitize error — execFileSync includes the full command with credentials
|
|
1542
|
+
const stderr = pushErr.stderr?.toString() || "";
|
|
1543
|
+
throw new Error(`git push failed: ${stderr.replace(/Authorization:[^\s]*/g, "Authorization: [REDACTED]")}`);
|
|
1544
|
+
}
|
|
1401
1545
|
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1546
|
+
// Post-push verification (defense-in-depth: server checks for merge commits)
|
|
1547
|
+
const verifyResult = await callToolEndpoint("POST", "post-push-verify", {
|
|
1548
|
+
commit_sha: headSha,
|
|
1549
|
+
expected_ref: expectedRef,
|
|
1550
|
+
branch,
|
|
1551
|
+
}, signal);
|
|
1552
|
+
if (verifyResult.error) {
|
|
1553
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(verifyResult) }] };
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ pushed: true, headSha, branch }) }] };
|
|
1410
1557
|
}
|
|
1411
1558
|
|
|
1559
|
+
// Local fallback: just push
|
|
1560
|
+
execFileSync("git", ["push"]);
|
|
1561
|
+
const branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"]).toString().trim();
|
|
1412
1562
|
return { content: [{ type: "text" as const, text: JSON.stringify({ pushed: true, headSha, branch }) }] };
|
|
1413
|
-
}
|
|
1414
|
-
|
|
1415
|
-
// Local fallback: just push
|
|
1416
|
-
execFileSync("git", ["push"]);
|
|
1417
|
-
const branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"]).toString().trim();
|
|
1418
|
-
return { content: [{ type: "text" as const, text: JSON.stringify({ pushed: true, headSha, branch }) }] };
|
|
1563
|
+
}, signal);
|
|
1419
1564
|
},
|
|
1420
1565
|
});
|
|
1421
1566
|
|
|
@@ -1437,35 +1582,38 @@ When you're done, provide a clear, actionable summary the caller can act on.`;
|
|
|
1437
1582
|
draft: Type.Optional(Type.Boolean({ description: "Create as draft PR. Defaults to false." })),
|
|
1438
1583
|
}),
|
|
1439
1584
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1585
|
+
// Serialize mutating GitHub operations to prevent race conditions (v0.58.0+ parallel tool execution)
|
|
1586
|
+
return githubMutex.acquire(async () => {
|
|
1587
|
+
if (isGHA) {
|
|
1588
|
+
const data = await callToolEndpoint("POST", "pr", {
|
|
1589
|
+
title: params.title,
|
|
1590
|
+
body: params.body,
|
|
1591
|
+
draft: params.draft,
|
|
1592
|
+
}, signal);
|
|
1593
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
|
|
1594
|
+
}
|
|
1448
1595
|
|
|
1449
|
-
|
|
1450
|
-
try {
|
|
1451
|
-
// Try to update existing PR first
|
|
1452
|
-
execFileSync("gh", ["pr", "edit", "--title", params.title, "--body", params.body]);
|
|
1453
|
-
const prJson = execFileSync("gh", ["pr", "view", "--json", "number,url"]).toString().trim();
|
|
1454
|
-
const pr = JSON.parse(prJson);
|
|
1455
|
-
return { content: [{ type: "text" as const, text: JSON.stringify({ prNumber: pr.number, prUrl: pr.url, created: false }) }] };
|
|
1456
|
-
} catch {
|
|
1457
|
-
// No existing PR — create one
|
|
1458
|
-
const createArgs = ["pr", "create", "--title", params.title, "--body", params.body];
|
|
1459
|
-
if (params.draft) createArgs.push("--draft");
|
|
1460
|
-
const prUrl = execFileSync("gh", createArgs).toString().trim();
|
|
1461
|
-
// Fetch PR number from the newly created PR
|
|
1462
|
-
let prNumber: number | undefined;
|
|
1596
|
+
// Local fallback: gh CLI
|
|
1463
1597
|
try {
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1598
|
+
// Try to update existing PR first
|
|
1599
|
+
execFileSync("gh", ["pr", "edit", "--title", params.title, "--body", params.body]);
|
|
1600
|
+
const prJson = execFileSync("gh", ["pr", "view", "--json", "number,url"]).toString().trim();
|
|
1601
|
+
const pr = JSON.parse(prJson);
|
|
1602
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ prNumber: pr.number, prUrl: pr.url, created: false }) }] };
|
|
1603
|
+
} catch {
|
|
1604
|
+
// No existing PR — create one
|
|
1605
|
+
const createArgs = ["pr", "create", "--title", params.title, "--body", params.body];
|
|
1606
|
+
if (params.draft) createArgs.push("--draft");
|
|
1607
|
+
const prUrl = execFileSync("gh", createArgs).toString().trim();
|
|
1608
|
+
// Fetch PR number from the newly created PR
|
|
1609
|
+
let prNumber: number | undefined;
|
|
1610
|
+
try {
|
|
1611
|
+
const newPrJson = execFileSync("gh", ["pr", "view", "--json", "number"]).toString().trim();
|
|
1612
|
+
prNumber = JSON.parse(newPrJson).number;
|
|
1613
|
+
} catch {}
|
|
1614
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ prNumber, prUrl, created: true }) }] };
|
|
1615
|
+
}
|
|
1616
|
+
}, signal);
|
|
1469
1617
|
},
|
|
1470
1618
|
});
|
|
1471
1619
|
|
|
@@ -1492,25 +1640,28 @@ When you're done, provide a clear, actionable summary the caller can act on.`;
|
|
|
1492
1640
|
),
|
|
1493
1641
|
}),
|
|
1494
1642
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1643
|
+
// Serialize mutating GitHub operations to prevent race conditions (v0.58.0+ parallel tool execution)
|
|
1644
|
+
return githubMutex.acquire(async () => {
|
|
1645
|
+
if (isGHA) {
|
|
1646
|
+
const data = await callToolEndpoint("POST", "comment", {
|
|
1647
|
+
body: params.body,
|
|
1648
|
+
target: params.target,
|
|
1649
|
+
reviewCommentId: params.reviewCommentId,
|
|
1650
|
+
}, signal);
|
|
1651
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
|
|
1652
|
+
}
|
|
1503
1653
|
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1654
|
+
// Local fallback: gh CLI
|
|
1655
|
+
if (params.target === "pr_review_thread") {
|
|
1656
|
+
throw new Error("pr_review_thread target is not supported in local CLI mode — use GHA or reply manually");
|
|
1657
|
+
}
|
|
1658
|
+
if (params.target === "issue") {
|
|
1659
|
+
execFileSync("gh", ["issue", "comment", "--body", params.body]);
|
|
1660
|
+
} else {
|
|
1661
|
+
execFileSync("gh", ["pr", "comment", "--body", params.body]);
|
|
1662
|
+
}
|
|
1663
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ commentUrl: "comment posted locally" }) }] };
|
|
1664
|
+
}, signal);
|
|
1514
1665
|
},
|
|
1515
1666
|
});
|
|
1516
1667
|
|
|
@@ -1547,27 +1698,30 @@ When you're done, provide a clear, actionable summary the caller can act on.`;
|
|
|
1547
1698
|
),
|
|
1548
1699
|
}),
|
|
1549
1700
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1701
|
+
// Serialize mutating GitHub operations to prevent race conditions (v0.58.0+ parallel tool execution)
|
|
1702
|
+
return githubMutex.acquire(async () => {
|
|
1703
|
+
if (isGHA) {
|
|
1704
|
+
const data = await callToolEndpoint("POST", "review", {
|
|
1705
|
+
body: params.body,
|
|
1706
|
+
event: params.event,
|
|
1707
|
+
comments: params.comments,
|
|
1708
|
+
}, signal);
|
|
1709
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
|
|
1710
|
+
}
|
|
1558
1711
|
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1712
|
+
// Local fallback: gh pr review
|
|
1713
|
+
if (params.comments && params.comments.length > 0) {
|
|
1714
|
+
throw new Error("Inline review comments are not supported in local CLI mode — use GHA for inline comments");
|
|
1715
|
+
}
|
|
1716
|
+
const reviewArgs = ["pr", "review", "--body", params.body];
|
|
1717
|
+
if (params.event === "REQUEST_CHANGES") {
|
|
1718
|
+
reviewArgs.push("--request-changes");
|
|
1719
|
+
} else {
|
|
1720
|
+
reviewArgs.push("--comment");
|
|
1721
|
+
}
|
|
1722
|
+
execFileSync("gh", reviewArgs);
|
|
1723
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ reviewUrl: "review posted locally" }) }] };
|
|
1724
|
+
}, signal);
|
|
1571
1725
|
},
|
|
1572
1726
|
});
|
|
1573
1727
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "naiad-cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.39",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"naiad": "./dist/index.js"
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"test": "tsx --test src/callback/server.test.ts"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"@mariozechner/pi-coding-agent": "^0.
|
|
24
|
+
"@mariozechner/pi-coding-agent": "^0.58.3"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
27
|
"@types/node": "^22.0.0",
|