quadwork 1.2.1 → 1.2.3

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 (85) hide show
  1. package/out/404.html +1 -1
  2. package/out/__next.__PAGE__.txt +3 -3
  3. package/out/__next._full.txt +12 -12
  4. package/out/__next._head.txt +4 -4
  5. package/out/__next._index.txt +6 -6
  6. package/out/__next._tree.txt +2 -2
  7. package/out/_next/static/chunks/0-yus965h3bk_.js +24 -0
  8. package/out/_next/static/chunks/0d.f~y5jeh785.css +2 -0
  9. package/out/_next/static/chunks/{064engxz5n7u9.js → 0e~ue9ca5zrep.js} +1 -1
  10. package/out/_next/static/chunks/0md7hgvwnovzq.js +1 -0
  11. package/out/_next/static/chunks/0whtwwbpg72ar.js +1 -0
  12. package/out/_next/static/chunks/{0o97ax9om2kj1.js → 16ell.n1p8o7d.js} +1 -1
  13. package/out/_not-found/__next._full.txt +11 -11
  14. package/out/_not-found/__next._head.txt +4 -4
  15. package/out/_not-found/__next._index.txt +6 -6
  16. package/out/_not-found/__next._not-found.__PAGE__.txt +2 -2
  17. package/out/_not-found/__next._not-found.txt +3 -3
  18. package/out/_not-found/__next._tree.txt +2 -2
  19. package/out/_not-found.html +1 -1
  20. package/out/_not-found.txt +11 -11
  21. package/out/app-shell/__next._full.txt +11 -11
  22. package/out/app-shell/__next._head.txt +4 -4
  23. package/out/app-shell/__next._index.txt +6 -6
  24. package/out/app-shell/__next._tree.txt +2 -2
  25. package/out/app-shell/__next.app-shell.__PAGE__.txt +2 -2
  26. package/out/app-shell/__next.app-shell.txt +3 -3
  27. package/out/app-shell.html +1 -1
  28. package/out/app-shell.txt +11 -11
  29. package/out/index.html +1 -1
  30. package/out/index.txt +12 -12
  31. package/out/project/_/__next._full.txt +12 -12
  32. package/out/project/_/__next._head.txt +4 -4
  33. package/out/project/_/__next._index.txt +6 -6
  34. package/out/project/_/__next._tree.txt +2 -2
  35. package/out/project/_/__next.project.$d$id.__PAGE__.txt +3 -3
  36. package/out/project/_/__next.project.$d$id.txt +3 -3
  37. package/out/project/_/__next.project.txt +3 -3
  38. package/out/project/_/memory/__next._full.txt +12 -12
  39. package/out/project/_/memory/__next._head.txt +4 -4
  40. package/out/project/_/memory/__next._index.txt +6 -6
  41. package/out/project/_/memory/__next._tree.txt +2 -2
  42. package/out/project/_/memory/__next.project.$d$id.memory.__PAGE__.txt +3 -3
  43. package/out/project/_/memory/__next.project.$d$id.memory.txt +3 -3
  44. package/out/project/_/memory/__next.project.$d$id.txt +3 -3
  45. package/out/project/_/memory/__next.project.txt +3 -3
  46. package/out/project/_/memory.html +1 -1
  47. package/out/project/_/memory.txt +12 -12
  48. package/out/project/_/queue/__next._full.txt +12 -12
  49. package/out/project/_/queue/__next._head.txt +4 -4
  50. package/out/project/_/queue/__next._index.txt +6 -6
  51. package/out/project/_/queue/__next._tree.txt +2 -2
  52. package/out/project/_/queue/__next.project.$d$id.queue.__PAGE__.txt +3 -3
  53. package/out/project/_/queue/__next.project.$d$id.queue.txt +3 -3
  54. package/out/project/_/queue/__next.project.$d$id.txt +3 -3
  55. package/out/project/_/queue/__next.project.txt +3 -3
  56. package/out/project/_/queue.html +1 -1
  57. package/out/project/_/queue.txt +12 -12
  58. package/out/project/_.html +1 -1
  59. package/out/project/_.txt +12 -12
  60. package/out/settings/__next._full.txt +12 -12
  61. package/out/settings/__next._head.txt +4 -4
  62. package/out/settings/__next._index.txt +6 -6
  63. package/out/settings/__next._tree.txt +2 -2
  64. package/out/settings/__next.settings.__PAGE__.txt +3 -3
  65. package/out/settings/__next.settings.txt +3 -3
  66. package/out/settings.html +1 -1
  67. package/out/settings.txt +12 -12
  68. package/out/setup/__next._full.txt +12 -12
  69. package/out/setup/__next._head.txt +4 -4
  70. package/out/setup/__next._index.txt +6 -6
  71. package/out/setup/__next._tree.txt +2 -2
  72. package/out/setup/__next.setup.__PAGE__.txt +3 -3
  73. package/out/setup/__next.setup.txt +3 -3
  74. package/out/setup.html +1 -1
  75. package/out/setup.txt +12 -12
  76. package/package.json +1 -1
  77. package/server/agentchattr-registry.js +105 -0
  78. package/server/index.js +125 -21
  79. package/server/routes.js +99 -10
  80. package/out/_next/static/chunks/03v5eoc-wic6o.js +0 -1
  81. package/out/_next/static/chunks/0738cfu-x.0ul.js +0 -24
  82. package/out/_next/static/chunks/0r-00ph4jahrl.css +0 -2
  83. /package/out/_next/static/{R3KHD-zZk76pfWNOR4boQ → 6W2vNw7Pp8z2_l_OJ2hqC}/_buildManifest.js +0 -0
  84. /package/out/_next/static/{R3KHD-zZk76pfWNOR4boQ → 6W2vNw7Pp8z2_l_OJ2hqC}/_clientMiddlewareManifest.js +0 -0
  85. /package/out/_next/static/{R3KHD-zZk76pfWNOR4boQ → 6W2vNw7Pp8z2_l_OJ2hqC}/_ssgManifest.js +0 -0
package/server/index.js CHANGED
@@ -8,6 +8,7 @@ const pty = require("node-pty");
8
8
  const { spawn } = require("child_process");
9
9
  const { readConfig, resolveAgentCwd, resolveAgentCommand, resolveProjectChattr, resolveChattrSpawn, syncChattrToken, CONFIG_PATH } = require("./config");
10
10
  const routes = require("./routes");
11
+ const { waitForAgentChattrReady, registerAgent, deregisterAgent } = require("./agentchattr-registry");
11
12
 
12
13
  const net = require("net");
13
14
  const config = readConfig();
@@ -207,6 +208,39 @@ const PERMISSION_FLAGS = {
207
208
  * Generate a per-agent MCP config file for Claude (--mcp-config).
208
209
  * Returns the absolute path to the written JSON file.
209
210
  */
211
+ /**
212
+ * Per-agent registration tokens persisted across QuadWork restarts so
213
+ * #242 stale-slot reclaim works after a crash. Without this the
214
+ * in-memory _tokenCache is empty on startup and the family-name
215
+ * deregister returns 403 (app.py:2123-2135).
216
+ */
217
+ function _agentTokenPath(projectId, agentId) {
218
+ const configDir = path.join(os.homedir(), ".quadwork", projectId);
219
+ return path.join(configDir, `agent-token-${agentId}.txt`);
220
+ }
221
+
222
+ function readPersistedAgentToken(projectId, agentId) {
223
+ try {
224
+ return fs.readFileSync(_agentTokenPath(projectId, agentId), "utf8").trim() || null;
225
+ } catch {
226
+ return null;
227
+ }
228
+ }
229
+
230
+ function writePersistedAgentToken(projectId, agentId, token) {
231
+ try {
232
+ const configDir = path.join(os.homedir(), ".quadwork", projectId);
233
+ if (!fs.existsSync(configDir)) fs.mkdirSync(configDir, { recursive: true });
234
+ fs.writeFileSync(_agentTokenPath(projectId, agentId), token, { mode: 0o600 });
235
+ } catch {
236
+ // non-fatal — stale-slot reclaim will degrade but registration still works
237
+ }
238
+ }
239
+
240
+ function clearPersistedAgentToken(projectId, agentId) {
241
+ try { fs.unlinkSync(_agentTokenPath(projectId, agentId)); } catch {}
242
+ }
243
+
210
244
  function writeMcpConfigFile(projectId, agentId, mcpHttpPort, token) {
211
245
  const os = require("os");
212
246
  const configDir = path.join(os.homedir(), ".quadwork", projectId);
@@ -233,12 +267,14 @@ function writeMcpConfigFile(projectId, agentId, mcpHttpPort, token) {
233
267
  async function buildAgentArgs(projectId, agentId) {
234
268
  const cfg = readConfig();
235
269
  const project = cfg.projects?.find((p) => p.id === projectId);
236
- if (!project) return [];
270
+ if (!project) return { args: [], acRegistrationName: null, acServerPort: null };
237
271
 
238
272
  const agentCfg = project.agents?.[agentId] || {};
239
273
  const command = agentCfg.command || "claude";
240
274
  const cliBase = command.split("/").pop().split(" ")[0]; // extract base CLI name
241
275
  const args = [];
276
+ let acRegistrationName = null;
277
+ let acServerPort = null;
242
278
 
243
279
  // Permission bypass flags
244
280
  if (agentCfg.auto_approve !== false) {
@@ -252,21 +288,63 @@ async function buildAgentArgs(projectId, agentId) {
252
288
  if (mcpHttpPort) {
253
289
  const injectMode = agentCfg.mcp_inject || (cliBase === "codex" ? "proxy_flag" : cliBase === "gemini" ? "env" : "flag");
254
290
  if (injectMode === "flag") {
255
- // Claude/Kimi: write config file, pass --mcp-config
256
- const mcpConfigPath = writeMcpConfigFile(projectId, agentId, mcpHttpPort, token);
291
+ // Claude/Kimi: register with AgentChattr to obtain a per-agent
292
+ // token (#239 session_token is browser auth, not MCP auth) and
293
+ // write that into the per-agent MCP config file.
294
+ const chattrInfo = resolveProjectChattr(projectId);
295
+ acServerPort = Number(new URL(chattrInfo.url).port) || 8300;
296
+ await waitForAgentChattrReady(acServerPort);
297
+ // #242: best-effort deregister any stale registration of the
298
+ // canonical name (left over by a crashed previous QuadWork
299
+ // session) so the fresh register lands at slot 1 instead of
300
+ // head-2 / reviewer2-2. We need the previous agent's bearer
301
+ // token because app.py:2123 requires authenticated agent
302
+ // session for family names — load it from disk (persisted
303
+ // across restarts). Failures are non-fatal.
304
+ const stalePersistedToken = readPersistedAgentToken(projectId, agentId);
305
+ if (stalePersistedToken) {
306
+ await deregisterAgent(acServerPort, agentId, stalePersistedToken).catch(() => {});
307
+ clearPersistedAgentToken(projectId, agentId);
308
+ }
309
+ const registration = await registerAgent(acServerPort, agentId, agentCfg.display_name || null);
310
+ if (!registration) {
311
+ throw new Error(`Failed to register ${agentId}: ${registerAgent.lastError}`);
312
+ }
313
+ acRegistrationName = registration.name;
314
+ writePersistedAgentToken(projectId, agentId, registration.token);
315
+ const mcpConfigPath = writeMcpConfigFile(projectId, agentId, mcpHttpPort, registration.token);
257
316
  const flag = agentCfg.mcp_flag || "--mcp-config";
258
317
  args.push(flag, mcpConfigPath);
259
318
  } else if (injectMode === "proxy_flag") {
260
- // Codex: start local auth proxy, pass proxy URL via -c flag
319
+ // Codex: register with AgentChattr first (#240) so the proxy
320
+ // injects a real per-agent token, not the global session token.
321
+ // Resolve via resolveProjectChattr so legacy/global-config
322
+ // projects without a per-project agentchattr_url still work.
323
+ const chattrInfo = resolveProjectChattr(projectId);
324
+ acServerPort = Number(new URL(chattrInfo.url).port) || 8300;
325
+ await waitForAgentChattrReady(acServerPort);
326
+ // #242: best-effort deregister stale canonical name first using
327
+ // the persisted bearer token from a previous session.
328
+ const stalePersistedToken = readPersistedAgentToken(projectId, agentId);
329
+ if (stalePersistedToken) {
330
+ await deregisterAgent(acServerPort, agentId, stalePersistedToken).catch(() => {});
331
+ clearPersistedAgentToken(projectId, agentId);
332
+ }
333
+ const registration = await registerAgent(acServerPort, agentId, agentCfg.display_name || null);
334
+ if (!registration) {
335
+ throw new Error(`Failed to register ${agentId}: ${registerAgent.lastError}`);
336
+ }
337
+ acRegistrationName = registration.name;
338
+ writePersistedAgentToken(projectId, agentId, registration.token);
261
339
  const upstreamUrl = `http://127.0.0.1:${mcpHttpPort}`;
262
- const proxyUrl = await startMcpProxy(projectId, agentId, upstreamUrl, token);
340
+ const proxyUrl = await startMcpProxy(projectId, agentId, upstreamUrl, registration.token);
263
341
  if (proxyUrl) {
264
342
  args.push("-c", `mcp_servers.agentchattr.url="${proxyUrl}"`);
265
343
  }
266
344
  }
267
345
  }
268
346
 
269
- return args;
347
+ return { args, acRegistrationName, acServerPort };
270
348
  }
271
349
 
272
350
  /**
@@ -313,7 +391,8 @@ async function spawnAgentPty(project, agent) {
313
391
  if (!cwd) return { ok: false, error: `Unknown agent: ${key}` };
314
392
 
315
393
  const command = resolveAgentCommand(project, agent) || (process.env.SHELL || "/bin/zsh");
316
- const args = await buildAgentArgs(project, agent);
394
+ const built = await buildAgentArgs(project, agent);
395
+ const args = built.args;
317
396
  const extraEnv = buildAgentEnv(project, agent);
318
397
 
319
398
  try {
@@ -325,7 +404,16 @@ async function spawnAgentPty(project, agent) {
325
404
  env: { ...process.env, ...extraEnv },
326
405
  });
327
406
 
328
- const session = { projectId: project, agentId: agent, term, ws: null, state: "running", error: null };
407
+ const session = {
408
+ projectId: project,
409
+ agentId: agent,
410
+ term,
411
+ ws: null,
412
+ state: "running",
413
+ error: null,
414
+ acRegistrationName: built.acRegistrationName,
415
+ acServerPort: built.acServerPort,
416
+ };
329
417
  agentSessions.set(key, session);
330
418
 
331
419
  term.onExit(({ exitCode }) => {
@@ -349,8 +437,11 @@ async function spawnAgentPty(project, agent) {
349
437
  }
350
438
  }
351
439
 
352
- // Helper: stop an agent session — kill PTY, close WS
353
- function stopAgentSession(key) {
440
+ // Helper: stop an agent session — kill PTY, close WS, deregister.
441
+ // Async because deregister must complete before a restart re-registers,
442
+ // otherwise the old slot stays occupied and a fresh register lands at
443
+ // head-2 instead of slot 1 (#241).
444
+ async function stopAgentSession(key) {
354
445
  const session = agentSessions.get(key);
355
446
  if (!session) {
356
447
  agentSessions.set(key, { projectId: null, agentId: null, term: null, ws: null, state: "stopped", error: null });
@@ -366,6 +457,19 @@ function stopAgentSession(key) {
366
457
  session.ws = null;
367
458
  session.state = "stopped";
368
459
  session.error = null;
460
+ // Best-effort deregister from AgentChattr (#241) so the slot frees
461
+ // and the next register lands at slot 1 instead of head-2.
462
+ if (session.acRegistrationName && session.acServerPort) {
463
+ try {
464
+ await deregisterAgent(session.acServerPort, session.acRegistrationName);
465
+ } catch {
466
+ // best-effort — failures are non-fatal
467
+ }
468
+ if (session.projectId && session.agentId) {
469
+ clearPersistedAgentToken(session.projectId, session.agentId);
470
+ }
471
+ session.acRegistrationName = null;
472
+ }
369
473
  // Clean up MCP auth proxy if running
370
474
  const [projectId, agentId] = key.split("/");
371
475
  if (projectId && agentId) stopMcpProxy(projectId, agentId);
@@ -630,10 +734,10 @@ app.post("/api/agents/:project/:agent/start", async (req, res) => {
630
734
 
631
735
  // --- Lifecycle: stop kills PTY + closes WS ---
632
736
 
633
- app.post("/api/agents/:project/:agent/stop", (req, res) => {
737
+ app.post("/api/agents/:project/:agent/stop", async (req, res) => {
634
738
  const { project, agent } = req.params;
635
739
  const key = `${project}/${agent}`;
636
- stopAgentSession(key);
740
+ await stopAgentSession(key);
637
741
  res.json({ ok: true, state: "stopped" });
638
742
  });
639
743
 
@@ -643,16 +747,16 @@ app.post("/api/agents/:project/:agent/restart", async (req, res) => {
643
747
  const { project, agent } = req.params;
644
748
  const key = `${project}/${agent}`;
645
749
 
646
- stopAgentSession(key);
750
+ // #241: must await deregister before respawn so the slot frees and
751
+ // the fresh register lands at slot 1 instead of head-2.
752
+ await stopAgentSession(key);
647
753
 
648
- setTimeout(async () => {
649
- const result = await spawnAgentPty(project, agent);
650
- if (result.ok) {
651
- res.json({ ok: true, state: "running", pid: result.pid });
652
- } else {
653
- res.status(500).json({ ok: false, state: "error", error: result.error });
654
- }
655
- }, 500);
754
+ const result = await spawnAgentPty(project, agent);
755
+ if (result.ok) {
756
+ res.json({ ok: true, state: "running", pid: result.pid });
757
+ } else {
758
+ res.status(500).json({ ok: false, state: "error", error: result.error });
759
+ }
656
760
  });
657
761
 
658
762
  // --- Sessions tracking (for /api/projects dashboard) ---
package/server/routes.js CHANGED
@@ -122,19 +122,108 @@ router.get("/api/chat", async (req, res) => {
122
122
  }
123
123
  });
124
124
 
125
+ // #225 sub-E: send chat messages from the dashboard via the
126
+ // AgentChattr WebSocket, not via /api/send.
127
+ //
128
+ // /api/send requires `Authorization: Bearer <registration_token>` and
129
+ // the token must resolve to a registered instance via
130
+ // `registry.resolve_token()`. The session_token we store on the
131
+ // project entry only authorizes browser/middleware traffic — it is
132
+ // NOT a registration token, so /api/send always 401s with
133
+ // "missing Authorization: Bearer <token>". The dashboard browser
134
+ // already sends through the WebSocket on `/ws?token=<session_token>`
135
+ // and the server accepts that path, so we mirror that exact flow
136
+ // from the express server: open a one-shot ws, push the message,
137
+ // wait briefly for ack, close.
138
+ const { WebSocket: NodeWebSocket } = require("ws");
139
+ const { syncChattrToken } = require("./config");
140
+
141
+ function sendViaWebSocket(baseUrl, sessionToken, message) {
142
+ return new Promise((resolve, reject) => {
143
+ const wsUrl = `${baseUrl.replace(/^http/, "ws")}/ws?token=${encodeURIComponent(sessionToken || "")}`;
144
+ const ws = new NodeWebSocket(wsUrl);
145
+ let settled = false;
146
+ const finish = (err, value) => {
147
+ if (settled) return;
148
+ settled = true;
149
+ try { ws.close(); } catch {}
150
+ if (err) reject(err); else resolve(value);
151
+ };
152
+ const giveUp = setTimeout(() => finish(new Error("websocket send timeout")), 4000);
153
+ ws.on("open", () => {
154
+ try {
155
+ ws.send(JSON.stringify({ type: "message", ...message }));
156
+ // Server acks via broadcast, but the dashboard's POST /api/chat
157
+ // contract only needs to know the message was accepted. Wait
158
+ // ~250ms for the server to enqueue + close cleanly.
159
+ setTimeout(() => { clearTimeout(giveUp); finish(null, { ok: true }); }, 250);
160
+ } catch (err) { clearTimeout(giveUp); finish(err); }
161
+ });
162
+ ws.on("error", (err) => { clearTimeout(giveUp); finish(err); });
163
+ ws.on("close", (code, reason) => {
164
+ // Code 4003 = bad token (see app.py /ws handler). Surface as
165
+ // 401 so the dashboard's chat error banner shows the right thing.
166
+ if (!settled && code === 4003) {
167
+ clearTimeout(giveUp);
168
+ const msg = (reason && reason.toString()) || "forbidden: invalid session token";
169
+ const e = new Error(msg);
170
+ e.code = "EAGENTCHATTR_401";
171
+ finish(e);
172
+ }
173
+ });
174
+ });
175
+ }
176
+
125
177
  router.post("/api/chat", async (req, res) => {
126
- const { url: base, token } = getChattrConfig(req.query.project || req.body.project);
127
- const tokenParam = token ? `?token=${encodeURIComponent(token)}` : "";
178
+ const projectId = req.query.project || req.body.project;
179
+ const { url: base, token: sessionToken } = getChattrConfig(projectId);
180
+ if (!base) return res.status(400).json({ error: "Missing project" });
181
+
182
+ // #230: ignore any client-supplied sender. /api/chat is the
183
+ // dashboard's send path, so the message must always be attributed
184
+ // to "user". Forwarding `req.body.sender` would let any caller
185
+ // hitting QuadWork's /api/chat impersonate an agent identity (t1,
186
+ // t3, …) over the AgentChattr ws path, which the old /api/send
187
+ // flow could not do.
188
+ const message = {
189
+ text: typeof req.body?.text === "string" ? req.body.text : "",
190
+ channel: req.body?.channel || "general",
191
+ sender: "user",
192
+ attachments: Array.isArray(req.body?.attachments) ? req.body.attachments : [],
193
+ };
194
+ if (!message.text && message.attachments.length === 0) {
195
+ return res.status(400).json({ error: "text or attachments required" });
196
+ }
197
+
198
+ const attemptSend = () => sendViaWebSocket(base, sessionToken, message);
199
+
128
200
  try {
129
- const r = await fetch(`${base}/api/send${tokenParam}`, {
130
- method: "POST",
131
- headers: { "Content-Type": "application/json", ...chatAuthHeaders(token) },
132
- body: JSON.stringify(req.body),
133
- });
134
- if (!r.ok) return res.status(r.status).json({ error: `AgentChattr returned ${r.status}` });
135
- res.json(await r.json());
201
+ await attemptSend();
202
+ return res.json({ ok: true });
136
203
  } catch (err) {
137
- res.status(502).json({ error: "AgentChattr unreachable", detail: err.message });
204
+ // If the cached session_token is stale (AgentChattr regenerates
205
+ // one on every restart) the ws closes with code 4003 — re-sync
206
+ // the token from AgentChattr's HTML and retry once before giving
207
+ // up. This is the actual fix for the "401 after restart" report
208
+ // in #230 (the cache was stuck on an old token).
209
+ if (err && err.code === "EAGENTCHATTR_401") {
210
+ console.warn(`[chat] ws auth failed for project ${projectId}, re-syncing session token and retrying...`);
211
+ try { await syncChattrToken(projectId); }
212
+ catch (syncErr) { console.warn(`[chat] syncChattrToken failed: ${syncErr.message}`); }
213
+ const { token: refreshed } = getChattrConfig(projectId);
214
+ if (refreshed && refreshed !== sessionToken) {
215
+ try {
216
+ await sendViaWebSocket(base, refreshed, message);
217
+ return res.json({ ok: true, resynced: true });
218
+ } catch (retryErr) {
219
+ console.warn(`[chat] retry after token resync failed: ${retryErr.message}`);
220
+ return res.status(401).json({ error: "AgentChattr auth failed (token resync did not help)", detail: retryErr.message });
221
+ }
222
+ }
223
+ return res.status(401).json({ error: "AgentChattr auth failed", detail: err.message });
224
+ }
225
+ console.warn(`[chat] send failed for project ${projectId}: ${err && err.message}`);
226
+ return res.status(502).json({ error: "AgentChattr unreachable", detail: err && err.message });
138
227
  }
139
228
  });
140
229
 
@@ -1 +0,0 @@
1
- (globalThis.TURBOPACK||(globalThis.TURBOPACK=[])).push(["object"==typeof document?document.currentScript:void 0,16348,e=>{"use strict";var t=e.i(85899),s=e.i(4232),r=e.i(2270);e.s(["default",0,function(){let[e,a]=(0,s.useState)([]),[c,i]=(0,s.useState)([]);return(0,s.useEffect)(()=>{fetch("/api/projects").then(e=>{if(!e.ok)throw Error(`${e.status}`);return e.json()}).then(e=>{e.projects&&Array.isArray(e.projects)&&a(e.projects.filter(e=>!e.archived)),e.recentEvents&&Array.isArray(e.recentEvents)&&i(e.recentEvents)}).catch(()=>{})},[]),(0,t.jsxs)("div",{className:"h-full overflow-y-auto p-6",children:[(0,t.jsxs)("div",{className:"mb-6",children:[(0,t.jsx)("h1",{className:"text-lg font-semibold text-text tracking-tight",children:"Projects"}),(0,t.jsxs)("p",{className:"text-xs text-text-muted mt-1",children:[e.length," configured project",1!==e.length?"s":""]})]}),(0,t.jsxs)("div",{className:"grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3 mb-8",children:[e.map(e=>(0,t.jsxs)(r.default,{href:`/project/${e.id}`,className:"block border border-border bg-bg-surface p-4 hover:bg-[#1a1a1a] transition-colors group",children:[(0,t.jsxs)("div",{className:"flex items-center justify-between mb-3",children:[(0,t.jsxs)("div",{className:"flex items-center gap-2",children:[(0,t.jsx)("span",{className:`w-1.5 h-1.5 rounded-full ${"active"===e.state?"bg-accent":"bg-text-muted"}`}),(0,t.jsx)("span",{className:"text-sm font-semibold text-text",children:e.name}),(0,t.jsx)("span",{className:"text-[10px] text-text-muted",children:e.state})]}),(0,t.jsx)("span",{className:"text-[10px] text-text-muted opacity-0 group-hover:opacity-100 transition-opacity",children:"open →"})]}),(0,t.jsxs)("div",{className:"flex gap-4 text-[11px] mb-2",children:[(0,t.jsxs)("div",{children:[(0,t.jsx)("span",{className:"text-text-muted",children:"agents"}),(0,t.jsx)("span",{className:"ml-1.5 text-text",children:e.agentCount})]}),(0,t.jsxs)("div",{children:[(0,t.jsx)("span",{className:"text-text-muted",children:"PRs"}),(0,t.jsx)("span",{className:"ml-1.5 text-text",children:e.openPrs})]}),(0,t.jsxs)("div",{children:[(0,t.jsx)("span",{className:"text-text-muted",children:"repo"}),(0,t.jsx)("span",{className:"ml-1.5 text-text",children:e.repo})]})]}),e.lastActivity&&(0,t.jsxs)("div",{className:"text-[10px] text-text-muted",children:["last activity: ",function(e){let t=Math.floor((Date.now()-new Date(e).getTime())/6e4);if(t<1)return"just now";if(t<60)return`${t}m ago`;let s=Math.floor(t/60);if(s<24)return`${s}h ago`;let r=Math.floor(s/24);return`${r}d ago`}(e.lastActivity)]})]},e.id)),(0,t.jsx)(r.default,{href:"/setup",className:"border border-dashed border-border p-4 flex items-center justify-center text-text-muted hover:text-text hover:border-text-muted transition-colors min-h-[88px]",children:(0,t.jsx)("span",{className:"text-sm",children:"+ New Project"})})]}),(0,t.jsxs)("div",{className:"mb-6",children:[(0,t.jsx)("h2",{className:"text-xs text-text-muted uppercase tracking-wider mb-3",children:"Recent Activity"}),(0,t.jsxs)("div",{className:"border border-border bg-bg-surface",children:[0===c.length&&(0,t.jsx)("div",{className:"px-3 py-3 text-[11px] text-text-muted",children:"No recent activity"}),c.map((e,s)=>(0,t.jsxs)("div",{className:"flex gap-3 px-3 py-1.5 border-b border-border/50 last:border-b-0 text-[11px]",children:[(0,t.jsx)("span",{className:"text-text-muted shrink-0 w-10 text-right tabular-nums",children:e.time?.slice(0,5)||""}),(0,t.jsx)("span",{className:"text-accent shrink-0 font-semibold w-12",children:e.projectName}),(0,t.jsx)("span",{className:"text-[#ffcc00] shrink-0 font-semibold w-6",children:e.actor}),(0,t.jsx)("span",{className:"text-text truncate min-w-0",children:e.text})]},`${e.time}-${s}`))]})]})]})}])}]);