heyio 0.21.4 → 0.23.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.
@@ -20,27 +20,6 @@ import { runScheduleNow } from "../copilot/scheduler.js";
20
20
  import { runIoScheduleNow } from "../copilot/io-scheduler.js";
21
21
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
22
22
  const WEB_DIST = path.resolve(__dirname, "../../web-dist");
23
- let releasesCache = null;
24
- function loadReleases() {
25
- if (releasesCache)
26
- return releasesCache;
27
- // Check both dev (src/api/ → src/) and production (dist/api/ → src/) paths
28
- const candidates = [
29
- path.join(__dirname, "../releases.json"), // dist/api/ → dist/releases.json
30
- path.join(__dirname, "../../src/releases.json"), // dist/api/ → src/releases.json
31
- ];
32
- for (const candidate of candidates) {
33
- try {
34
- if (!existsSync(candidate))
35
- continue;
36
- const raw = readFileSync(candidate, "utf-8");
37
- releasesCache = JSON.parse(raw);
38
- return releasesCache;
39
- }
40
- catch { /* try next */ }
41
- }
42
- return [];
43
- }
44
23
  let messageHandler;
45
24
  const sseConnections = new Set();
46
25
  export function setMessageHandler(handler) {
@@ -173,10 +152,6 @@ export async function startApiServer() {
173
152
  api.get("/status", (_req, res) => {
174
153
  res.json({ version: IO_VERSION, uptime: process.uptime() });
175
154
  });
176
- // Releases endpoint — serves build-time bundled GitHub release notes
177
- api.get("/releases", (_req, res) => {
178
- res.json({ releases: loadReleases() });
179
- });
180
155
  // SSE events endpoint
181
156
  api.get("/events", (req, res) => {
182
157
  res.setHeader("Content-Type", "text/event-stream");
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Pure session-handling logic extracted from web/src/stores/auth.ts.
3
+ *
4
+ * These functions contain the core behavioral decisions made across four
5
+ * consecutive auth patches (#189→#192→#194→#196) that fixed undocumented
6
+ * Supabase JS v2 side-effects. They are expressed as framework-agnostic
7
+ * functions so they can be unit-tested with the Node test runner without
8
+ * requiring Vue or Pinia.
9
+ *
10
+ * The authoritative implementation lives in web/src/stores/auth.ts.
11
+ * Any change to the auth store's session logic MUST be reflected here and
12
+ * all tests in session-logic.test.ts must continue to pass.
13
+ */
14
+ /**
15
+ * Mirrors the onAuthStateChange handler in web/src/stores/auth.ts.
16
+ *
17
+ * Decision: only SIGNED_OUT clears the cached session. All other events that
18
+ * fire with a null session (TOKEN_REFRESHED failure, lock timeout, internal
19
+ * reconciliation) are intentionally ignored to prevent cache poisoning.
20
+ *
21
+ * See squad decision 2026-05-16 03:17:52 and issue #193.
22
+ */
23
+ export function handleAuthStateChange(cachedSession, event, newSession) {
24
+ if (newSession) {
25
+ // Accept any valid session regardless of event type
26
+ return newSession;
27
+ }
28
+ if (event === "SIGNED_OUT") {
29
+ // Only explicit sign-out clears the cache
30
+ return null;
31
+ }
32
+ // All other null-session events: leave the existing cache untouched
33
+ return cachedSession;
34
+ }
35
+ /**
36
+ * Mirrors getAccessToken() in web/src/stores/auth.ts.
37
+ *
38
+ * Decisions:
39
+ * - Reads from cached session — NEVER calls getSession() (Supabase JS v2
40
+ * side-effect: can fire onAuthStateChange(null) during reconciliation).
41
+ * - Proactively refreshes when within 30 seconds of expiry.
42
+ * - Falls back to refreshSession() if cache is null and auth is enabled.
43
+ * - Returns null when auth is disabled.
44
+ *
45
+ * See squad decisions 2026-05-16 02:23:38, 2026-05-16 03:16:46 and issues
46
+ * #191, #193.
47
+ */
48
+ export async function getAccessToken(cachedSession, authEnabled, refresh, now = Date.now) {
49
+ if (cachedSession?.access_token) {
50
+ // Proactively refresh when within 30 seconds of expiry
51
+ if (cachedSession.expires_at !== undefined &&
52
+ cachedSession.expires_at * 1000 - now() < 30_000) {
53
+ try {
54
+ const fresh = await refresh();
55
+ if (fresh)
56
+ return fresh.access_token;
57
+ }
58
+ catch {
59
+ // fall through to cached token
60
+ }
61
+ }
62
+ return cachedSession.access_token;
63
+ }
64
+ // No cached session — attempt recovery if auth is enabled.
65
+ // Handles the edge case where the cache was spuriously cleared but a
66
+ // valid refresh token still exists in storage.
67
+ if (authEnabled) {
68
+ try {
69
+ const fresh = await refresh();
70
+ if (fresh)
71
+ return fresh.access_token;
72
+ }
73
+ catch {
74
+ // fall through to null
75
+ }
76
+ }
77
+ return null;
78
+ }
79
+ //# sourceMappingURL=session-logic.js.map
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Auth session handling regression tests — Supabase JS v2 integration decisions.
3
+ *
4
+ * These tests guard the behavioral decisions made across four consecutive auth
5
+ * patches (#189→#192→#194→#196) that fixed undocumented Supabase JS v2 side-
6
+ * effects. They test the logic extracted into src/auth/session-logic.ts, which
7
+ * mirrors the implementation in web/src/stores/auth.ts.
8
+ *
9
+ * If auth store logic changes, update session-logic.ts to match AND verify
10
+ * these tests still pass.
11
+ */
12
+ import { describe, it } from "node:test";
13
+ import assert from "node:assert/strict";
14
+ import { handleAuthStateChange, getAccessToken, } from "./session-logic.js";
15
+ // ── Helpers ───────────────────────────────────────────────────────────────────
16
+ function makeSession(overrides = {}) {
17
+ return {
18
+ access_token: "tok-abc",
19
+ expires_at: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now
20
+ user: { id: "user-1" },
21
+ ...overrides,
22
+ };
23
+ }
24
+ const neverCalled = async () => {
25
+ throw new Error("refresh should not have been called");
26
+ };
27
+ // Use a fixed epoch (multiple of 1000ms) for deterministic boundary tests.
28
+ // T = 1_000_000_000_000ms (epoch seconds = 1_000_000_000)
29
+ const FIXED_NOW_MS = 1_000_000_000_000;
30
+ const FIXED_NOW_S = FIXED_NOW_MS / 1000; // 1_000_000_000
31
+ // ── onAuthStateChange session caching ────────────────────────────────────────
32
+ describe("handleAuthStateChange — onAuthStateChange caching rules", () => {
33
+ it("accepts a valid session on SIGNED_IN", () => {
34
+ const s = makeSession();
35
+ const result = handleAuthStateChange(null, "SIGNED_IN", s);
36
+ assert.deepEqual(result, s);
37
+ });
38
+ it("accepts a valid session on INITIAL_SESSION", () => {
39
+ const s = makeSession();
40
+ const result = handleAuthStateChange(null, "INITIAL_SESSION", s);
41
+ assert.deepEqual(result, s);
42
+ });
43
+ it("accepts a valid session on TOKEN_REFRESHED", () => {
44
+ const old = makeSession({ access_token: "tok-old" });
45
+ const fresh = makeSession({ access_token: "tok-new" });
46
+ const result = handleAuthStateChange(old, "TOKEN_REFRESHED", fresh);
47
+ assert.equal(result?.access_token, "tok-new");
48
+ });
49
+ // Critical regression: SIGNED_OUT is the ONLY event that clears the cache.
50
+ it("clears session on SIGNED_OUT with null session", () => {
51
+ const s = makeSession();
52
+ const result = handleAuthStateChange(s, "SIGNED_OUT", null);
53
+ assert.equal(result, null);
54
+ });
55
+ // Critical regression: internal null-session events must NOT poison the cache.
56
+ it("does NOT clear session on TOKEN_REFRESHED with null — keeps existing cache", () => {
57
+ const cached = makeSession({ access_token: "tok-valid" });
58
+ const result = handleAuthStateChange(cached, "TOKEN_REFRESHED", null);
59
+ assert.equal(result?.access_token, "tok-valid");
60
+ });
61
+ it("does NOT clear session on INITIAL_SESSION with null — keeps existing cache", () => {
62
+ const cached = makeSession({ access_token: "tok-valid" });
63
+ const result = handleAuthStateChange(cached, "INITIAL_SESSION", null);
64
+ assert.equal(result?.access_token, "tok-valid");
65
+ });
66
+ it("does NOT clear session on USER_UPDATED with null — keeps existing cache", () => {
67
+ const cached = makeSession({ access_token: "tok-valid" });
68
+ const result = handleAuthStateChange(cached, "USER_UPDATED", null);
69
+ assert.equal(result?.access_token, "tok-valid");
70
+ });
71
+ it("returns null when cache is already null and null-session event fires", () => {
72
+ const result = handleAuthStateChange(null, "TOKEN_REFRESHED", null);
73
+ assert.equal(result, null);
74
+ });
75
+ it("null-session SIGNED_OUT with no prior cache stays null", () => {
76
+ const result = handleAuthStateChange(null, "SIGNED_OUT", null);
77
+ assert.equal(result, null);
78
+ });
79
+ });
80
+ // ── getAccessToken — cached session path ─────────────────────────────────────
81
+ describe("getAccessToken — reads from cached session", () => {
82
+ it("returns cached access token when session is valid and not near expiry", async () => {
83
+ const session = makeSession({ access_token: "tok-valid" });
84
+ const token = await getAccessToken(session, true, neverCalled);
85
+ assert.equal(token, "tok-valid");
86
+ });
87
+ it("returns null when auth is disabled and cache is null", async () => {
88
+ const token = await getAccessToken(null, false, neverCalled);
89
+ assert.equal(token, null);
90
+ });
91
+ });
92
+ // ── getAccessToken — proactive refresh near expiry ───────────────────────────
93
+ describe("getAccessToken — proactive refresh within 30s of expiry", () => {
94
+ it("calls refresh and returns new token when within 30s of expiry", async () => {
95
+ // Session expires in 25 seconds (within 30s threshold)
96
+ const session = makeSession({
97
+ access_token: "tok-expiring",
98
+ expires_at: Math.floor(Date.now() / 1000) + 25,
99
+ });
100
+ const freshSession = makeSession({ access_token: "tok-fresh" });
101
+ let refreshCalled = false;
102
+ const refresh = async () => { refreshCalled = true; return freshSession; };
103
+ const token = await getAccessToken(session, true, refresh);
104
+ assert.equal(refreshCalled, true);
105
+ assert.equal(token, "tok-fresh");
106
+ });
107
+ it("does NOT call refresh when expiry is more than 30s away", async () => {
108
+ const session = makeSession({
109
+ access_token: "tok-valid",
110
+ expires_at: Math.floor(Date.now() / 1000) + 60, // 60s away
111
+ });
112
+ let refreshCalled = false;
113
+ const refresh = async () => { refreshCalled = true; return null; };
114
+ const token = await getAccessToken(session, true, refresh);
115
+ assert.equal(refreshCalled, false);
116
+ assert.equal(token, "tok-valid");
117
+ });
118
+ it("falls back to cached token if refresh throws near expiry", async () => {
119
+ const session = makeSession({
120
+ access_token: "tok-expiring",
121
+ expires_at: Math.floor(Date.now() / 1000) + 10,
122
+ });
123
+ const refresh = async () => { throw new Error("network error"); };
124
+ const token = await getAccessToken(session, true, refresh);
125
+ assert.equal(token, "tok-expiring");
126
+ });
127
+ it("falls back to cached token if refresh returns null near expiry", async () => {
128
+ const session = makeSession({
129
+ access_token: "tok-expiring",
130
+ expires_at: Math.floor(Date.now() / 1000) + 10,
131
+ });
132
+ const refresh = async () => null;
133
+ const token = await getAccessToken(session, true, refresh);
134
+ assert.equal(token, "tok-expiring");
135
+ });
136
+ // Boundary: expires_at * 1000 - now == 30_000 is NOT < 30_000, so no refresh.
137
+ // Use FIXED_NOW_MS (exact multiple of 1000) to avoid floor-truncation artifacts.
138
+ it("does NOT refresh when exactly 30s remain (boundary is exclusive)", async () => {
139
+ const session = makeSession({
140
+ access_token: "tok-boundary",
141
+ expires_at: FIXED_NOW_S + 30, // exactly 30_000ms away from FIXED_NOW_MS
142
+ });
143
+ let refreshCalled = false;
144
+ const refresh = async () => { refreshCalled = true; return null; };
145
+ const token = await getAccessToken(session, true, refresh, () => FIXED_NOW_MS);
146
+ assert.equal(refreshCalled, false);
147
+ assert.equal(token, "tok-boundary");
148
+ });
149
+ // Boundary: expires_at * 1000 - now == 29_000 is < 30_000, so refresh fires.
150
+ it("refreshes when 29s remain (1s inside the 30s window)", async () => {
151
+ const freshSession = makeSession({ access_token: "tok-refreshed" });
152
+ const session = makeSession({
153
+ access_token: "tok-boundary",
154
+ expires_at: FIXED_NOW_S + 29, // 29_000ms away — within threshold
155
+ });
156
+ let refreshCalled = false;
157
+ const refresh = async () => { refreshCalled = true; return freshSession; };
158
+ const token = await getAccessToken(session, true, refresh, () => FIXED_NOW_MS);
159
+ assert.equal(refreshCalled, true);
160
+ assert.equal(token, "tok-refreshed");
161
+ });
162
+ });
163
+ // ── getAccessToken — recovery fallback when cache is null ────────────────────
164
+ describe("getAccessToken — recovery fallback when cache is null", () => {
165
+ it("attempts refreshSession() when cache is null and auth is enabled", async () => {
166
+ const freshSession = makeSession({ access_token: "tok-recovered" });
167
+ let refreshCalled = false;
168
+ const refresh = async () => { refreshCalled = true; return freshSession; };
169
+ const token = await getAccessToken(null, true, refresh);
170
+ assert.equal(refreshCalled, true);
171
+ assert.equal(token, "tok-recovered");
172
+ });
173
+ it("returns null when auth is enabled but refreshSession() returns null", async () => {
174
+ const token = await getAccessToken(null, true, async () => null);
175
+ assert.equal(token, null);
176
+ });
177
+ it("returns null when auth is enabled but refreshSession() throws", async () => {
178
+ const refresh = async () => { throw new Error("refresh failed"); };
179
+ const token = await getAccessToken(null, true, refresh);
180
+ assert.equal(token, null);
181
+ });
182
+ it("does NOT call refreshSession() when auth is disabled", async () => {
183
+ let refreshCalled = false;
184
+ const refresh = async () => { refreshCalled = true; return null; };
185
+ const token = await getAccessToken(null, false, refresh);
186
+ assert.equal(refreshCalled, false);
187
+ assert.equal(token, null);
188
+ });
189
+ });
190
+ // ── getAccessToken — session without expires_at ───────────────────────────────
191
+ describe("getAccessToken — session without expires_at", () => {
192
+ it("returns cached token without attempting refresh if expires_at is undefined", async () => {
193
+ const session = { access_token: "tok-no-expiry" };
194
+ let refreshCalled = false;
195
+ const refresh = async () => { refreshCalled = true; return null; };
196
+ const token = await getAccessToken(session, true, refresh);
197
+ assert.equal(refreshCalled, false);
198
+ assert.equal(token, "tok-no-expiry");
199
+ });
200
+ });
201
+ //# sourceMappingURL=session-logic.test.js.map
@@ -100,6 +100,14 @@ When an agent finishes a task, the other squad members automatically review the
100
100
  - When all QA approvals pass (or no QA agents exist) and the task result contains a GitHub PR URL, the PR is automatically promoted from draft to ready via \`gh pr ready\`.
101
101
  - Use \`squad_task_reviews\` to inspect the reviews on any completed task.
102
102
 
103
+ #### GitHub Self-Review Limitation
104
+ All squad agents share the repo owner's \`gh\` CLI identity. **GitHub blocks self-review** — \`gh pr review <number> --approve\` silently produces no review record when the same account authored the PR. This affects all squad-authored PRs.
105
+
106
+ **Workaround — review comments as audit trail:**
107
+ - Veto-capable reviewers must use \`gh pr review <number> --comment --body "LGTM — approved by <agent name>. <review summary>"\` instead of \`--approve\`. This creates a visible PR comment even though it is not a formal GitHub approval.
108
+ - For formal GitHub approval (required by branch protection rules if enabled), Michael must approve the PR himself or a separate bot/collaborator account must be configured.
109
+ - **Merge criteria:** all veto-capable members have posted an approving review comment, AND CI passes (\`gh pr checks <number> --watch\`), AND no merge conflicts. The team lead verifies all comments are present before merging.
110
+
103
111
  ### Squad Build Checklist
104
112
  After \`squad_create\`, before delegating real work:
105
113
  1. Add domain-specialist agents with \`squad_add_agent\` (use roles tailored to the project's stack).
@@ -1,10 +1,10 @@
1
1
  import { defineTool } from "@github/copilot-sdk";
2
2
  import { z } from "zod";
3
- import { execSync } from "child_process";
3
+ import { execSync, execFileSync } from "child_process";
4
4
  import { readFileSync, writeFileSync, readdirSync, statSync, existsSync, mkdirSync } from "fs";
5
5
  import { join, dirname, resolve } from "path";
6
6
  import { homedir } from "os";
7
- import { UNIVERSES } from "./universes.js";
7
+ import { UNIVERSES, getOrCreateUniverse } from "./universes.js";
8
8
  import { createFeedEntry } from "../store/feed.js";
9
9
  import { validateCron, nextRun } from "./cron.js";
10
10
  import { createIoSchedule, deleteIoSchedule, getIoSchedule, listIoSchedules, setIoScheduleEnabled, updateIoScheduleNextRun, } from "../store/io-schedules.js";
@@ -294,15 +294,15 @@ export function createTools(deps) {
294
294
  name: z.string().describe("Display name (e.g., 'IO Assistant')"),
295
295
  project_path: z.string().describe("Path to the project directory"),
296
296
  universe: z
297
- .enum(UNIVERSES.map((u) => u.id))
297
+ .string()
298
298
  .optional()
299
- .describe("80s universe theme. Options: a-team, transformers, thundercats, gi-joe, aliens, ghostbusters. Random if omitted."),
299
+ .describe("Universe theme for agent characters. Built-in: a-team, transformers, thundercats, gi-joe, aliens, ghostbusters, tmnt, star-wars, star-trek, lord-of-the-rings, the-office, parks-and-rec. Or provide any custom name. Random if omitted."),
300
300
  }),
301
301
  handler: async ({ slug, name, project_path, universe }) => {
302
302
  try {
303
303
  deps.createSquad(slug, name, project_path, universe);
304
304
  const squad = deps.getSquad(slug);
305
- const universeName = UNIVERSES.find((u) => u.id === squad?.universe)?.name ?? squad?.universe;
305
+ const universeName = squad?.universe ? getOrCreateUniverse(squad.universe).name : "random";
306
306
  return `Squad "${name}" created for ${project_path}\nUniverse: ${universeName}\n\nNext steps:\n1. Use \`squad_analyze\` to examine the project\n2. Use \`squad_add_agent\` to add specialists based on the analysis`;
307
307
  }
308
308
  catch (err) {
@@ -1123,11 +1123,11 @@ export function createTools(deps) {
1123
1123
  handler: async ({ pattern, path: searchPath, include }) => {
1124
1124
  console.error(`[io] grep tool called: ${pattern} in ${searchPath || "."}`);
1125
1125
  try {
1126
- let cmd = `grep -rn "${pattern.replace(/"/g, '\\"')}"`;
1126
+ const args = ["-rn", pattern];
1127
1127
  if (include)
1128
- cmd += ` --include="${include}"`;
1129
- cmd += ` ${searchPath || "."}`;
1130
- const result = execSync(cmd, {
1128
+ args.push(`--include=${include}`);
1129
+ args.push(searchPath || ".");
1130
+ const result = execFileSync("grep", args, {
1131
1131
  encoding: "utf-8",
1132
1132
  timeout: 30_000,
1133
1133
  maxBuffer: 1024 * 1024,
@@ -1245,31 +1245,30 @@ export function createTools(deps) {
1245
1245
  handler: async ({ action, repo, title, body, labels, assignees, number, base, head, state, limit, review_action }) => {
1246
1246
  console.error(`[io] github tool called: ${action} on ${repo}`);
1247
1247
  try {
1248
- let cmd;
1249
- const r = `--repo ${repo}`;
1248
+ let args;
1250
1249
  switch (action) {
1251
1250
  case "create_issue": {
1252
1251
  if (!title)
1253
1252
  return "Error: title is required for create_issue";
1254
- cmd = `gh issue create ${r} --title "${title.replace(/"/g, '\\"')}"`;
1253
+ args = ["issue", "create", "--repo", repo, "--title", title];
1255
1254
  if (body)
1256
- cmd += ` --body "${body.replace(/"/g, '\\"')}"`;
1255
+ args.push("--body", body);
1257
1256
  if (labels?.length)
1258
- cmd += ` --label "${labels.join(",")}"`;
1257
+ args.push("--label", labels.join(","));
1259
1258
  if (assignees?.length)
1260
- cmd += ` --assignee "${assignees.join(",")}"`;
1259
+ args.push("--assignee", assignees.join(","));
1261
1260
  break;
1262
1261
  }
1263
1262
  case "list_issues": {
1264
- cmd = `gh issue list ${r} --limit ${limit ?? 10}`;
1263
+ args = ["issue", "list", "--repo", repo, "--limit", String(limit ?? 10)];
1265
1264
  if (state)
1266
- cmd += ` --state ${state}`;
1265
+ args.push("--state", state);
1267
1266
  break;
1268
1267
  }
1269
1268
  case "view_issue": {
1270
1269
  if (!number)
1271
1270
  return "Error: number is required for view_issue";
1272
- cmd = `gh issue view ${number} ${r}`;
1271
+ args = ["issue", "view", String(number), "--repo", repo];
1273
1272
  break;
1274
1273
  }
1275
1274
  case "comment_issue": {
@@ -1277,37 +1276,37 @@ export function createTools(deps) {
1277
1276
  return "Error: number is required for comment_issue";
1278
1277
  if (!body)
1279
1278
  return "Error: body is required for comment_issue";
1280
- cmd = `gh issue comment ${number} ${r} --body "${body.replace(/"/g, '\\"')}"`;
1279
+ args = ["issue", "comment", String(number), "--repo", repo, "--body", body];
1281
1280
  break;
1282
1281
  }
1283
1282
  case "close_issue": {
1284
1283
  if (!number)
1285
1284
  return "Error: number is required for close_issue";
1286
- cmd = `gh issue close ${number} ${r}`;
1285
+ args = ["issue", "close", String(number), "--repo", repo];
1287
1286
  break;
1288
1287
  }
1289
1288
  case "create_pr": {
1290
1289
  if (!title)
1291
1290
  return "Error: title is required for create_pr";
1292
- cmd = `gh pr create ${r} --title "${title.replace(/"/g, '\\"')}"`;
1291
+ args = ["pr", "create", "--repo", repo, "--title", title];
1293
1292
  if (body)
1294
- cmd += ` --body "${body.replace(/"/g, '\\"')}"`;
1293
+ args.push("--body", body);
1295
1294
  if (base)
1296
- cmd += ` --base ${base}`;
1295
+ args.push("--base", base);
1297
1296
  if (head)
1298
- cmd += ` --head ${head}`;
1297
+ args.push("--head", head);
1299
1298
  break;
1300
1299
  }
1301
1300
  case "list_prs": {
1302
- cmd = `gh pr list ${r} --limit ${limit ?? 10}`;
1301
+ args = ["pr", "list", "--repo", repo, "--limit", String(limit ?? 10)];
1303
1302
  if (state)
1304
- cmd += ` --state ${state}`;
1303
+ args.push("--state", state);
1305
1304
  break;
1306
1305
  }
1307
1306
  case "view_pr": {
1308
1307
  if (!number)
1309
1308
  return "Error: number is required for view_pr";
1310
- cmd = `gh pr view ${number} ${r}`;
1309
+ args = ["pr", "view", String(number), "--repo", repo];
1311
1310
  break;
1312
1311
  }
1313
1312
  case "comment_pr": {
@@ -1315,7 +1314,7 @@ export function createTools(deps) {
1315
1314
  return "Error: number is required for comment_pr";
1316
1315
  if (!body)
1317
1316
  return "Error: body is required for comment_pr";
1318
- cmd = `gh pr comment ${number} ${r} --body "${body.replace(/"/g, '\\"')}"`;
1317
+ args = ["pr", "comment", String(number), "--repo", repo, "--body", body];
1319
1318
  break;
1320
1319
  }
1321
1320
  case "review_pr": {
@@ -1323,16 +1322,16 @@ export function createTools(deps) {
1323
1322
  return "Error: number is required for review_pr";
1324
1323
  if (!review_action)
1325
1324
  return "Error: review_action is required for review_pr (approve, request-changes, or comment)";
1326
- cmd = `gh pr review ${number} ${r} --${review_action}`;
1325
+ args = ["pr", "review", String(number), "--repo", repo, `--${review_action}`];
1327
1326
  if (body && (review_action === "request-changes" || review_action === "comment")) {
1328
- cmd += ` --body "${body.replace(/"/g, '\\"')}"`;
1327
+ args.push("--body", body);
1329
1328
  }
1330
1329
  break;
1331
1330
  }
1332
1331
  default:
1333
1332
  return `Unknown action: ${action}`;
1334
1333
  }
1335
- const result = execSync(cmd, {
1334
+ const result = execFileSync("gh", args, {
1336
1335
  encoding: "utf-8",
1337
1336
  timeout: 30_000,
1338
1337
  maxBuffer: 1024 * 1024,