quadwork 1.2.2 → 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 (75) hide show
  1. package/out/404.html +1 -1
  2. package/out/__next.__PAGE__.txt +1 -1
  3. package/out/__next._full.txt +1 -1
  4. package/out/__next._head.txt +1 -1
  5. package/out/__next._index.txt +1 -1
  6. package/out/__next._tree.txt +1 -1
  7. package/out/_not-found/__next._full.txt +1 -1
  8. package/out/_not-found/__next._head.txt +1 -1
  9. package/out/_not-found/__next._index.txt +1 -1
  10. package/out/_not-found/__next._not-found.__PAGE__.txt +1 -1
  11. package/out/_not-found/__next._not-found.txt +1 -1
  12. package/out/_not-found/__next._tree.txt +1 -1
  13. package/out/_not-found.html +1 -1
  14. package/out/_not-found.txt +1 -1
  15. package/out/app-shell/__next._full.txt +1 -1
  16. package/out/app-shell/__next._head.txt +1 -1
  17. package/out/app-shell/__next._index.txt +1 -1
  18. package/out/app-shell/__next._tree.txt +1 -1
  19. package/out/app-shell/__next.app-shell.__PAGE__.txt +1 -1
  20. package/out/app-shell/__next.app-shell.txt +1 -1
  21. package/out/app-shell.html +1 -1
  22. package/out/app-shell.txt +1 -1
  23. package/out/index.html +1 -1
  24. package/out/index.txt +1 -1
  25. package/out/project/_/__next._full.txt +1 -1
  26. package/out/project/_/__next._head.txt +1 -1
  27. package/out/project/_/__next._index.txt +1 -1
  28. package/out/project/_/__next._tree.txt +1 -1
  29. package/out/project/_/__next.project.$d$id.__PAGE__.txt +1 -1
  30. package/out/project/_/__next.project.$d$id.txt +1 -1
  31. package/out/project/_/__next.project.txt +1 -1
  32. package/out/project/_/memory/__next._full.txt +1 -1
  33. package/out/project/_/memory/__next._head.txt +1 -1
  34. package/out/project/_/memory/__next._index.txt +1 -1
  35. package/out/project/_/memory/__next._tree.txt +1 -1
  36. package/out/project/_/memory/__next.project.$d$id.memory.__PAGE__.txt +1 -1
  37. package/out/project/_/memory/__next.project.$d$id.memory.txt +1 -1
  38. package/out/project/_/memory/__next.project.$d$id.txt +1 -1
  39. package/out/project/_/memory/__next.project.txt +1 -1
  40. package/out/project/_/memory.html +1 -1
  41. package/out/project/_/memory.txt +1 -1
  42. package/out/project/_/queue/__next._full.txt +1 -1
  43. package/out/project/_/queue/__next._head.txt +1 -1
  44. package/out/project/_/queue/__next._index.txt +1 -1
  45. package/out/project/_/queue/__next._tree.txt +1 -1
  46. package/out/project/_/queue/__next.project.$d$id.queue.__PAGE__.txt +1 -1
  47. package/out/project/_/queue/__next.project.$d$id.queue.txt +1 -1
  48. package/out/project/_/queue/__next.project.$d$id.txt +1 -1
  49. package/out/project/_/queue/__next.project.txt +1 -1
  50. package/out/project/_/queue.html +1 -1
  51. package/out/project/_/queue.txt +1 -1
  52. package/out/project/_.html +1 -1
  53. package/out/project/_.txt +1 -1
  54. package/out/settings/__next._full.txt +1 -1
  55. package/out/settings/__next._head.txt +1 -1
  56. package/out/settings/__next._index.txt +1 -1
  57. package/out/settings/__next._tree.txt +1 -1
  58. package/out/settings/__next.settings.__PAGE__.txt +1 -1
  59. package/out/settings/__next.settings.txt +1 -1
  60. package/out/settings.html +1 -1
  61. package/out/settings.txt +1 -1
  62. package/out/setup/__next._full.txt +1 -1
  63. package/out/setup/__next._head.txt +1 -1
  64. package/out/setup/__next._index.txt +1 -1
  65. package/out/setup/__next._tree.txt +1 -1
  66. package/out/setup/__next.setup.__PAGE__.txt +1 -1
  67. package/out/setup/__next.setup.txt +1 -1
  68. package/out/setup.html +1 -1
  69. package/out/setup.txt +1 -1
  70. package/package.json +1 -1
  71. package/server/agentchattr-registry.js +105 -0
  72. package/server/index.js +125 -21
  73. /package/out/_next/static/{x1j09EJSQ1T6SDM0qG0LZ → 6W2vNw7Pp8z2_l_OJ2hqC}/_buildManifest.js +0 -0
  74. /package/out/_next/static/{x1j09EJSQ1T6SDM0qG0LZ → 6W2vNw7Pp8z2_l_OJ2hqC}/_clientMiddlewareManifest.js +0 -0
  75. /package/out/_next/static/{x1j09EJSQ1T6SDM0qG0LZ → 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) ---