quadwork 0.1.3 → 1.0.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.
Files changed (98) hide show
  1. package/README.md +58 -83
  2. package/bin/quadwork.js +372 -58
  3. package/out/404.html +1 -1
  4. package/out/__next.__PAGE__.txt +1 -1
  5. package/out/__next._full.txt +2 -2
  6. package/out/__next._head.txt +1 -1
  7. package/out/__next._index.txt +2 -2
  8. package/out/__next._tree.txt +2 -2
  9. package/out/_next/static/chunks/0ahp74n0wkel0.js +1 -0
  10. package/out/_next/static/chunks/{038g944ax83al.js → 0dmi9pk2bd712.js} +3 -3
  11. package/out/_next/static/chunks/0ezniz80psxr6.js +1 -0
  12. package/out/_next/static/chunks/0g-nq4.uckan-.js +1 -0
  13. package/out/_next/static/chunks/0io_y3d0p5v~b.js +2 -0
  14. package/out/_next/static/chunks/0jt42fqe6jaw6.js +1 -0
  15. package/out/_next/static/chunks/{0wda-2lcle8c4.js → 0q5hwcek8vu2q.js} +12 -12
  16. package/out/_next/static/chunks/0r_tb4lmfa_yb.js +1 -0
  17. package/out/_next/static/chunks/0s8jbc4nxw6y6.css +2 -0
  18. package/out/_next/static/chunks/0z~0.4hivi.f2.js +31 -0
  19. package/out/_next/static/chunks/135rms05ismy4.js +13 -0
  20. package/out/_next/static/chunks/14kr4rvjq-2md.js +1 -0
  21. package/out/_next/static/chunks/turbopack-0sammtvunroor.js +1 -0
  22. package/out/_not-found/__next._full.txt +2 -2
  23. package/out/_not-found/__next._head.txt +1 -1
  24. package/out/_not-found/__next._index.txt +2 -2
  25. package/out/_not-found/__next._not-found.__PAGE__.txt +1 -1
  26. package/out/_not-found/__next._not-found.txt +1 -1
  27. package/out/_not-found/__next._tree.txt +2 -2
  28. package/out/_not-found.html +1 -1
  29. package/out/_not-found.txt +2 -2
  30. package/out/app-shell/__next._full.txt +18 -0
  31. package/out/app-shell/__next._head.txt +6 -0
  32. package/out/app-shell/__next._index.txt +6 -0
  33. package/out/app-shell/__next._tree.txt +3 -0
  34. package/out/app-shell/__next.app-shell.__PAGE__.txt +5 -0
  35. package/out/app-shell/__next.app-shell.txt +5 -0
  36. package/out/app-shell.html +1 -0
  37. package/out/app-shell.txt +18 -0
  38. package/out/index.html +1 -1
  39. package/out/index.txt +2 -2
  40. package/out/project/_/__next._full.txt +3 -4
  41. package/out/project/_/__next._head.txt +1 -1
  42. package/out/project/_/__next._index.txt +2 -2
  43. package/out/project/_/__next._tree.txt +2 -3
  44. package/out/project/_/__next.project.$d$id.__PAGE__.txt +2 -3
  45. package/out/project/_/__next.project.$d$id.txt +1 -1
  46. package/out/project/_/__next.project.txt +1 -1
  47. package/out/project/_/memory/__next._full.txt +3 -3
  48. package/out/project/_/memory/__next._head.txt +1 -1
  49. package/out/project/_/memory/__next._index.txt +2 -2
  50. package/out/project/_/memory/__next._tree.txt +2 -2
  51. package/out/project/_/memory/__next.project.$d$id.memory.__PAGE__.txt +2 -2
  52. package/out/project/_/memory/__next.project.$d$id.memory.txt +1 -1
  53. package/out/project/_/memory/__next.project.$d$id.txt +1 -1
  54. package/out/project/_/memory/__next.project.txt +1 -1
  55. package/out/project/_/memory.html +1 -1
  56. package/out/project/_/memory.txt +3 -3
  57. package/out/project/_/queue/__next._full.txt +3 -3
  58. package/out/project/_/queue/__next._head.txt +1 -1
  59. package/out/project/_/queue/__next._index.txt +2 -2
  60. package/out/project/_/queue/__next._tree.txt +2 -2
  61. package/out/project/_/queue/__next.project.$d$id.queue.__PAGE__.txt +2 -2
  62. package/out/project/_/queue/__next.project.$d$id.queue.txt +1 -1
  63. package/out/project/_/queue/__next.project.$d$id.txt +1 -1
  64. package/out/project/_/queue/__next.project.txt +1 -1
  65. package/out/project/_/queue.html +1 -1
  66. package/out/project/_/queue.txt +3 -3
  67. package/out/project/_.html +1 -1
  68. package/out/project/_.txt +3 -4
  69. package/out/settings/__next._full.txt +3 -3
  70. package/out/settings/__next._head.txt +1 -1
  71. package/out/settings/__next._index.txt +2 -2
  72. package/out/settings/__next._tree.txt +2 -2
  73. package/out/settings/__next.settings.__PAGE__.txt +2 -2
  74. package/out/settings/__next.settings.txt +1 -1
  75. package/out/settings.html +1 -1
  76. package/out/settings.txt +3 -3
  77. package/out/setup/__next._full.txt +3 -3
  78. package/out/setup/__next._head.txt +1 -1
  79. package/out/setup/__next._index.txt +2 -2
  80. package/out/setup/__next._tree.txt +2 -2
  81. package/out/setup/__next.setup.__PAGE__.txt +2 -2
  82. package/out/setup/__next.setup.txt +1 -1
  83. package/out/setup.html +1 -1
  84. package/out/setup.txt +3 -3
  85. package/package.json +1 -1
  86. package/server/config.js +25 -1
  87. package/server/index.js +248 -15
  88. package/server/routes.js +91 -2
  89. package/out/_next/static/chunks/0.dzh0qf9zq1l.js +0 -2
  90. package/out/_next/static/chunks/02ul7y114vj2f.js +0 -13
  91. package/out/_next/static/chunks/0gy_9ugdx7ueh.js +0 -1
  92. package/out/_next/static/chunks/0idtc5k0469of.js +0 -1
  93. package/out/_next/static/chunks/0yxmvmvm1dx_d.css +0 -2
  94. package/out/_next/static/chunks/13uu.sohs74zg.js +0 -31
  95. package/out/_next/static/chunks/turbopack-06pqx~0d8czn_.js +0 -1
  96. /package/out/_next/static/{91YUiFoMbLQ9sZW4uk45J → 4vrILyy2mh_Ox4JMTaqx8}/_buildManifest.js +0 -0
  97. /package/out/_next/static/{91YUiFoMbLQ9sZW4uk45J → 4vrILyy2mh_Ox4JMTaqx8}/_clientMiddlewareManifest.js +0 -0
  98. /package/out/_next/static/{91YUiFoMbLQ9sZW4uk45J → 4vrILyy2mh_Ox4JMTaqx8}/_ssgManifest.js +0 -0
package/server/index.js CHANGED
@@ -5,9 +5,10 @@ const fs = require("fs");
5
5
  const { WebSocketServer } = require("ws");
6
6
  const pty = require("node-pty");
7
7
  const { spawn } = require("child_process");
8
- const { readConfig, resolveAgentCwd, resolveAgentCommand, resolveProjectChattr } = require("./config");
8
+ const { readConfig, resolveAgentCwd, resolveAgentCommand, resolveProjectChattr, syncChattrToken } = require("./config");
9
9
  const routes = require("./routes");
10
10
 
11
+ const net = require("net");
11
12
  const config = readConfig();
12
13
  const PORT = config.port || 8400;
13
14
 
@@ -25,6 +26,103 @@ app.get("/api/health", (_req, res) => {
25
26
  res.json({ status: "ok" });
26
27
  });
27
28
 
29
+ // --- CLI status detection ---
30
+
31
+ const { execSync } = require("child_process");
32
+
33
+ function isCliInstalled(cmd) {
34
+ try {
35
+ execSync(`which ${cmd}`, { encoding: "utf-8", stdio: "pipe" });
36
+ return true;
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+
42
+ app.get("/api/cli-status", (_req, res) => {
43
+ res.json({
44
+ claude: isCliInstalled("claude"),
45
+ codex: isCliInstalled("codex"),
46
+ });
47
+ });
48
+
49
+ // --- Port availability check ---
50
+
51
+ function checkPort(port) {
52
+ return new Promise((resolve) => {
53
+ const srv = net.createServer();
54
+ srv.once("error", () => resolve(false));
55
+ srv.once("listening", () => { srv.close(); resolve(true); });
56
+ srv.listen(port, "127.0.0.1");
57
+ });
58
+ }
59
+
60
+ app.get("/api/port-check", async (req, res) => {
61
+ const port = parseInt(req.query.port, 10);
62
+ if (!port || port < 1 || port > 65535) {
63
+ return res.status(400).json({ error: "Invalid port" });
64
+ }
65
+ const free = await checkPort(port);
66
+ res.json({ port, free });
67
+ });
68
+
69
+ app.get("/api/port-check/auto", async (req, res) => {
70
+ const start = parseInt(req.query.start, 10) || 8300;
71
+ const count = Math.min(parseInt(req.query.count, 10) || 3, 10);
72
+ const results = [];
73
+ let port = start;
74
+ for (let i = 0; i < count; i++) {
75
+ while (!(await checkPort(port)) && port < 65535) port++;
76
+ results.push(port);
77
+ port++;
78
+ }
79
+ res.json({ ports: results });
80
+ });
81
+
82
+ // --- Caffeinate (sleep prevention) ---
83
+
84
+ let caffeinateProcess = { process: null, pid: null, startedAt: null, duration: null };
85
+
86
+ app.post("/api/caffeinate/start", (req, res) => {
87
+ if (process.platform !== "darwin") {
88
+ return res.status(400).json({ ok: false, error: "Sleep prevention is only available on macOS" });
89
+ }
90
+ // Kill existing if running
91
+ if (caffeinateProcess.process) {
92
+ try { caffeinateProcess.process.kill("SIGTERM"); } catch {}
93
+ }
94
+ const duration = req.body?.duration || 0; // seconds, 0 = indefinite
95
+ const args = ["-d", "-i", "-s"];
96
+ if (duration > 0) args.push("-t", String(duration));
97
+ const child = spawn("caffeinate", args, { stdio: "ignore", detached: true });
98
+ child.unref();
99
+ child.on("exit", () => {
100
+ if (caffeinateProcess.process === child) {
101
+ caffeinateProcess = { process: null, pid: null, startedAt: null, duration: null };
102
+ }
103
+ });
104
+ caffeinateProcess = { process: child, pid: child.pid, startedAt: Date.now(), duration: duration || null };
105
+ res.json({ ok: true, active: true, pid: child.pid, duration });
106
+ });
107
+
108
+ app.post("/api/caffeinate/stop", (_req, res) => {
109
+ if (caffeinateProcess.process) {
110
+ try { caffeinateProcess.process.kill("SIGTERM"); } catch {}
111
+ }
112
+ caffeinateProcess = { process: null, pid: null, startedAt: null, duration: null };
113
+ res.json({ ok: true, active: false });
114
+ });
115
+
116
+ app.get("/api/caffeinate/status", (_req, res) => {
117
+ const active = !!(caffeinateProcess.process && caffeinateProcess.pid);
118
+ let remaining = null;
119
+ if (active && caffeinateProcess.duration && caffeinateProcess.startedAt) {
120
+ const elapsed = Math.floor((Date.now() - caffeinateProcess.startedAt) / 1000);
121
+ remaining = Math.max(0, caffeinateProcess.duration - elapsed);
122
+ }
123
+ res.json({ active, pid: caffeinateProcess.pid, remaining, platform: process.platform });
124
+ });
125
+
28
126
  // --- Unified agent sessions ---
29
127
  // Single map: key = "project/agent" → { projectId, agentId, term, ws, state, error }
30
128
  // PTY (term) is the source of truth for "running". WS is optional (attaches to view terminal).
@@ -136,7 +234,20 @@ function handleAgentChattr(req, res) {
136
234
  chattrProcesses.set(projectId, val);
137
235
  }
138
236
 
237
+ function regenerateConfigToml() {
238
+ // If project has a config.toml, update the port to match current config
239
+ if (!projectConfigToml || !fs.existsSync(projectConfigToml)) return;
240
+ try {
241
+ let content = fs.readFileSync(projectConfigToml, "utf-8");
242
+ content = content.replace(/^port = \d+/m, `port = ${chattrPort}`);
243
+ fs.writeFileSync(projectConfigToml, content);
244
+ } catch {}
245
+ }
246
+
139
247
  function spawnChattr() {
248
+ // Sync config.toml port before starting
249
+ regenerateConfigToml();
250
+
140
251
  // Use project config.toml if available (isolated data dir + ports), otherwise fall back to --port
141
252
  const args = (projectConfigToml && fs.existsSync(projectConfigToml))
142
253
  ? ["--config", projectConfigToml]
@@ -146,6 +257,15 @@ function handleAgentChattr(req, res) {
146
257
  stdio: "ignore",
147
258
  detached: true,
148
259
  });
260
+
261
+ // If pid is undefined, spawn failed (binary not found)
262
+ if (!child.pid) {
263
+ setProc({ process: null, state: "error", error: "Failed to start AgentChattr — is it installed? Run: pipx install agentchattr" });
264
+ // Still register error handler to prevent unhandled error crash
265
+ child.on("error", () => {});
266
+ return null;
267
+ }
268
+
149
269
  child.unref();
150
270
  child.on("error", (err) => {
151
271
  setProc({ process: null, state: "error", error: err.message });
@@ -167,6 +287,12 @@ function handleAgentChattr(req, res) {
167
287
  }
168
288
  try {
169
289
  const child = spawnChattr();
290
+ if (!child) {
291
+ const errProc = getProc();
292
+ return res.status(500).json({ ok: false, state: "error", error: errProc.error || "Failed to start AgentChattr" });
293
+ }
294
+ // Sync token after AgentChattr starts (it generates its own)
295
+ setTimeout(() => syncChattrToken(projectId), 2000);
170
296
  res.json({ ok: true, state: "running", pid: child.pid });
171
297
  } catch (err) {
172
298
  setProc({ process: null, state: "error", error: err.message });
@@ -188,6 +314,12 @@ function handleAgentChattr(req, res) {
188
314
  setTimeout(() => {
189
315
  try {
190
316
  const child = spawnChattr();
317
+ if (!child) {
318
+ const errProc = getProc();
319
+ return res.status(500).json({ ok: false, state: "error", error: errProc.error || "Failed to start AgentChattr" });
320
+ }
321
+ // Sync token after AgentChattr restarts
322
+ setTimeout(() => syncChattrToken(projectId), 2000);
191
323
  res.json({ ok: true, state: "running", pid: child.pid });
192
324
  } catch (err) {
193
325
  setProc({ process: null, state: "error", error: err.message });
@@ -201,6 +333,44 @@ function handleAgentChattr(req, res) {
201
333
  app.post("/api/agentchattr/:projectOrAction/:action", handleAgentChattr);
202
334
  app.post("/api/agentchattr/:projectOrAction", handleAgentChattr);
203
335
 
336
+ // --- Reset agents: deregister all registered slots ---
337
+ // AgentChattr doesn't expose staleness metadata, so this clears all slots.
338
+ // Agents' wrapper heartbeat will auto-re-register with clean names.
339
+
340
+ app.post("/api/agents/:project/reset", async (req, res) => {
341
+ const projectId = req.params.project;
342
+ const { url: chattrUrl, token: chattrToken } = resolveProjectChattr(projectId);
343
+ const headers = {};
344
+ if (chattrToken) headers["x-session-token"] = chattrToken;
345
+
346
+ try {
347
+ // Fetch current agent status from AgentChattr
348
+ const statusRes = await fetch(`${chattrUrl}/api/status`, { headers });
349
+ if (!statusRes.ok) {
350
+ return res.status(statusRes.status).json({ ok: false, error: `AgentChattr status failed: ${statusRes.status}` });
351
+ }
352
+ const status = await statusRes.json();
353
+ const slots = status.agents || status.slots || [];
354
+
355
+ let cleared = 0;
356
+ for (const agent of slots) {
357
+ const name = typeof agent === "string" ? agent : agent.name || agent.sender;
358
+ if (!name) continue;
359
+ try {
360
+ const dereg = await fetch(`${chattrUrl}/api/deregister/${encodeURIComponent(name)}`, {
361
+ method: "POST",
362
+ headers,
363
+ });
364
+ if (dereg.ok) cleared++;
365
+ } catch {}
366
+ }
367
+
368
+ res.json({ ok: true, cleared, total: slots.length });
369
+ } catch (err) {
370
+ res.status(500).json({ ok: false, error: err.message });
371
+ }
372
+ });
373
+
204
374
  // --- Lifecycle: start spawns PTY (visible in terminal panel) ---
205
375
 
206
376
  app.post("/api/agents/:project/:agent/start", (req, res) => {
@@ -340,29 +510,60 @@ app.get("/api/triggers", (_req, res) => {
340
510
  lastSent: info.lastSent,
341
511
  nextAt: info.nextAt,
342
512
  lastError: info.lastError || null,
513
+ expiresAt: info.expiresAt || null,
343
514
  };
344
515
  }
345
516
  res.json(result);
346
517
  });
347
518
 
519
+ function stopTrigger(project) {
520
+ const existing = triggers.get(project);
521
+ if (existing) {
522
+ if (existing.timer) clearInterval(existing.timer);
523
+ if (existing.durationTimer) clearTimeout(existing.durationTimer);
524
+ }
525
+ triggers.delete(project);
526
+ }
527
+
348
528
  app.post("/api/triggers/:project/start", (req, res) => {
349
529
  const { project } = req.params;
350
- const { interval } = req.body || {};
530
+ const { interval, duration } = req.body || {};
351
531
  const ms = (interval || 30) * 60 * 1000;
532
+ const durationMs = duration ? duration * 60 * 1000 : 0; // duration in minutes, 0 = indefinite
352
533
 
353
534
  const existing = triggers.get(project);
354
- if (existing && existing.timer) clearInterval(existing.timer);
535
+ if (existing) {
536
+ if (existing.timer) clearInterval(existing.timer);
537
+ if (existing.durationTimer) clearTimeout(existing.durationTimer);
538
+ }
355
539
 
356
540
  const timer = setInterval(() => sendTriggerMessage(project), ms);
357
- triggers.set(project, { interval: ms, timer, lastSent: null, nextAt: Date.now() + ms });
358
- res.json({ ok: true, enabled: true, interval: ms, nextAt: Date.now() + ms });
541
+ const expiresAt = durationMs > 0 ? Date.now() + durationMs : null;
542
+
543
+ const triggerInfo = {
544
+ interval: ms,
545
+ timer,
546
+ lastSent: null,
547
+ nextAt: Date.now() + ms,
548
+ lastError: null,
549
+ expiresAt,
550
+ durationTimer: null,
551
+ };
552
+
553
+ // Auto-stop after duration
554
+ if (durationMs > 0) {
555
+ triggerInfo.durationTimer = setTimeout(() => {
556
+ stopTrigger(project);
557
+ }, durationMs);
558
+ }
559
+
560
+ triggers.set(project, triggerInfo);
561
+ res.json({ ok: true, enabled: true, interval: ms, nextAt: Date.now() + ms, expiresAt });
359
562
  });
360
563
 
361
564
  app.post("/api/triggers/:project/stop", (req, res) => {
362
565
  const { project } = req.params;
363
- const existing = triggers.get(project);
364
- if (existing && existing.timer) clearInterval(existing.timer);
365
- triggers.delete(project);
566
+ stopTrigger(project);
366
567
  res.json({ ok: true, enabled: false });
367
568
  });
368
569
 
@@ -382,20 +583,46 @@ app.set("syncTriggers", syncTriggersFromConfig);
382
583
 
383
584
  // --- Serve static frontend (built Next.js export) ---
384
585
 
586
+ // Strip trailing slashes (redirect /settings/ → /settings, /setup/ → /setup)
587
+ app.use((req, res, next) => {
588
+ if (req.path !== "/" && req.path.endsWith("/")) {
589
+ const clean = req.path.slice(0, -1);
590
+ const query = req.url.includes("?") ? req.url.slice(req.url.indexOf("?")) : "";
591
+ return res.redirect(301, clean + query);
592
+ }
593
+ next();
594
+ });
595
+
385
596
  const outDir = path.join(__dirname, "..", "out");
597
+
598
+ // HEAD requests: express.static extensions don't resolve when a same-name
599
+ // directory exists (e.g. /settings dir shadows settings.html for HEAD).
600
+ // Resolve extensionless HEAD requests to their .html file explicitly.
601
+ app.use((req, res, next) => {
602
+ if (req.method !== "HEAD" || req.path.startsWith("/api/") || path.extname(req.path)) {
603
+ return next();
604
+ }
605
+ const htmlPath = path.join(outDir, req.path + ".html");
606
+ if (fs.existsSync(htmlPath)) {
607
+ return res.sendFile(htmlPath);
608
+ }
609
+ next();
610
+ });
611
+
386
612
  if (fs.existsSync(outDir)) {
387
- app.use(express.static(outDir));
613
+ app.use(express.static(outDir, { redirect: false, extensions: ["html"] }));
388
614
  }
389
615
 
390
- // SPA fallback: serve the correct pre-rendered HTML for dynamic routes.
391
- // Static export only generates templates for placeholder params (e.g. /project/_),
392
- // so we map real dynamic segments back to those template files.
616
+ // SPA fallback: serve the pre-rendered template for dynamic routes,
617
+ // fall back to index.html for everything else.
393
618
  app.use((req, res, next) => {
394
- if (req.method !== "GET" || req.path.startsWith("/api/")) {
619
+ if ((req.method !== "GET" && req.method !== "HEAD") || req.path.startsWith("/api/")) {
395
620
  return next();
396
621
  }
397
622
 
398
- // Map dynamic routes to their pre-rendered template HTML
623
+ // Dynamic routes serve their pre-rendered template (has the right JS chunks).
624
+ // Hydration #418 is cosmetic — dashboard renders and functions correctly.
625
+ // NOTE: app-shell.html does NOT work — it has no route JS chunks and renders blank.
399
626
  const dynamicRoutes = [
400
627
  { pattern: /^\/project\/[^/]+\/memory\/?$/, template: "project/_/memory.html" },
401
628
  { pattern: /^\/project\/[^/]+\/queue\/?$/, template: "project/_/queue.html" },
@@ -411,7 +638,7 @@ app.use((req, res, next) => {
411
638
  }
412
639
  }
413
640
 
414
- // Default fallback to index.html
641
+ // Everything else index.html
415
642
  const indexPath = path.join(outDir, "index.html");
416
643
  if (fs.existsSync(indexPath)) {
417
644
  res.sendFile(indexPath);
@@ -511,6 +738,7 @@ function syncTriggersFromConfig() {
511
738
  for (const [id, info] of triggers) {
512
739
  if (!activeIds.has(id)) {
513
740
  if (info.timer) clearInterval(info.timer);
741
+ if (info.durationTimer) clearTimeout(info.durationTimer);
514
742
  triggers.delete(id);
515
743
  }
516
744
  }
@@ -521,4 +749,9 @@ function syncTriggersFromConfig() {
521
749
  server.listen(PORT, "127.0.0.1", () => {
522
750
  console.log(`QuadWork server listening on http://127.0.0.1:${PORT}`);
523
751
  syncTriggersFromConfig();
752
+ // Sync AgentChattr tokens for all projects on startup
753
+ const startupCfg = readConfig();
754
+ for (const p of (startupCfg.projects || [])) {
755
+ syncChattrToken(p.id);
756
+ }
524
757
  });
package/server/routes.js CHANGED
@@ -456,6 +456,73 @@ function exec(cmd, args, opts) {
456
456
  }
457
457
  }
458
458
 
459
+ // ─── GitHub helpers for Setup Wizard ──────────────────────────────────────
460
+
461
+ // GitHub user info
462
+ router.get("/api/github/user", (_req, res) => {
463
+ try {
464
+ const out = execFileSync("gh", ["api", "user", "--jq", "{login: .login}"], { encoding: "utf-8", timeout: 10000 });
465
+ res.json(JSON.parse(out));
466
+ } catch {
467
+ res.status(502).json({ error: "GitHub CLI not authenticated" });
468
+ }
469
+ });
470
+
471
+ // GitHub repo list for an owner (only repos with push access)
472
+ router.get("/api/github/repos", (req, res) => {
473
+ const owner = req.query.owner;
474
+ if (!owner) return res.status(400).json({ error: "Missing owner" });
475
+ try {
476
+ const out = execFileSync("gh", ["repo", "list", String(owner), "--json", "name,description,isPrivate,viewerPermission", "--limit", "50"], { encoding: "utf-8", timeout: 15000 });
477
+ const repos = JSON.parse(out);
478
+ // Filter to repos with push access (ADMIN, MAINTAIN, WRITE)
479
+ const pushAccess = new Set(["ADMIN", "MAINTAIN", "WRITE"]);
480
+ res.json(repos.filter((r) => pushAccess.has(r.viewerPermission)));
481
+ } catch {
482
+ res.json([]);
483
+ }
484
+ });
485
+
486
+ // Auto-detect existing clone of a repo
487
+ router.get("/api/setup/detect-clone", (req, res) => {
488
+ const repoName = req.query.repo; // "owner/repo"
489
+ if (!repoName) return res.status(400).json({ error: "Missing repo" });
490
+ const slug = String(repoName).split("/").pop();
491
+ const home = os.homedir();
492
+ const searchDirs = [
493
+ path.join(home, "Projects"),
494
+ path.join(home, "Developer"),
495
+ path.join(home, "repos"),
496
+ path.join(home, "code"),
497
+ path.join(home, "src"),
498
+ path.join(home, "workspace"),
499
+ home,
500
+ ];
501
+ for (const dir of searchDirs) {
502
+ const candidate = path.join(dir, slug);
503
+ if (fs.existsSync(path.join(candidate, ".git"))) {
504
+ return res.json({ found: true, path: candidate, suggested: path.join(searchDirs[0], slug) });
505
+ }
506
+ }
507
+ // Not found — suggest a default location
508
+ const defaultDir = fs.existsSync(searchDirs[0]) ? searchDirs[0] : home;
509
+ return res.json({ found: false, path: null, suggested: path.join(defaultDir, slug) });
510
+ });
511
+
512
+ // Save reviewer token securely
513
+ router.post("/api/setup/save-token", (req, res) => {
514
+ const { token } = req.body;
515
+ if (!token) return res.status(400).json({ error: "Missing token" });
516
+ const tokenPath = path.join(os.homedir(), ".quadwork", "reviewer-token");
517
+ const dir = path.dirname(tokenPath);
518
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
519
+ fs.writeFileSync(tokenPath, token.trim() + "\n", { mode: 0o600 });
520
+ try { fs.chmodSync(tokenPath, 0o600); } catch {}
521
+ res.json({ ok: true, path: tokenPath });
522
+ });
523
+
524
+ // ─── Setup Wizard ─────────────────────────────────────────────────────────
525
+
459
526
  router.post("/api/setup", (req, res) => {
460
527
  const step = req.query.step;
461
528
  const body = req.body || {};
@@ -464,8 +531,16 @@ router.post("/api/setup", (req, res) => {
464
531
  case "verify-repo": {
465
532
  const repo = body.repo;
466
533
  if (!repo || !REPO_RE.test(repo)) return res.json({ ok: false, error: "Invalid repo format (use owner/repo)" });
467
- const result = exec("gh", ["repo", "view", repo, "--json", "name,owner"]);
468
- return res.json({ ok: result.ok, error: result.ok ? undefined : "Cannot access repo. Check gh auth and repo permissions." });
534
+ const result = exec("gh", ["repo", "view", repo, "--json", "name,owner,viewerPermission"]);
535
+ if (!result.ok) return res.json({ ok: false, error: "Cannot access repo. Check gh auth and repo permissions." });
536
+ try {
537
+ const info = JSON.parse(result.output);
538
+ const pushAccess = new Set(["ADMIN", "MAINTAIN", "WRITE"]);
539
+ if (!pushAccess.has(info.viewerPermission)) {
540
+ return res.json({ ok: false, error: "You don't have push access to this repo. Agents need push access to create branches and PRs." });
541
+ }
542
+ } catch {}
543
+ return res.json({ ok: true });
469
544
  }
470
545
  case "create-worktrees": {
471
546
  const workingDir = body.workingDir;
@@ -544,6 +619,20 @@ router.post("/api/setup", (req, res) => {
544
619
  }
545
620
  seeded.push(`${agent}/CLAUDE.md`);
546
621
  }
622
+
623
+ // .gitignore — ensure token files are never committed
624
+ const gitignorePath = path.join(wtDir, ".gitignore");
625
+ const tokenIgnorePatterns = "reviewer-token\n*-token\n";
626
+ if (!fs.existsSync(gitignorePath)) {
627
+ fs.writeFileSync(gitignorePath, tokenIgnorePatterns);
628
+ seeded.push(`${agent}/.gitignore`);
629
+ } else {
630
+ const existing = fs.readFileSync(gitignorePath, "utf-8");
631
+ if (!existing.includes("*-token")) {
632
+ fs.appendFileSync(gitignorePath, "\n" + tokenIgnorePatterns);
633
+ seeded.push(`${agent}/.gitignore (updated)`);
634
+ }
635
+ }
547
636
  }
548
637
  return res.json({ ok: true, seeded });
549
638
  }