quadwork 1.3.0 → 1.5.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 (93) hide show
  1. package/README.md +189 -82
  2. package/bin/quadwork.js +82 -0
  3. package/out/404.html +1 -1
  4. package/out/__next.__PAGE__.txt +3 -3
  5. package/out/__next._full.txt +12 -12
  6. package/out/__next._head.txt +4 -4
  7. package/out/__next._index.txt +6 -6
  8. package/out/__next._tree.txt +2 -2
  9. package/out/_next/static/chunks/{134b1p_egmf1c.js → 0-y13tz~pmpno.js} +1 -1
  10. package/out/_next/static/chunks/{0swlbn4q4u71z.js → 0.9m84as-sc_r.js} +14 -14
  11. package/out/_next/static/chunks/05.po0c1knrbu.css +2 -0
  12. package/out/_next/static/chunks/084lff9v4p_vh.js +1 -0
  13. package/out/_next/static/chunks/{0md7hgvwnovzq.js → 0e.ktwt1nyj...js} +1 -1
  14. package/out/_next/static/chunks/{0e~ue9ca5zrep.js → 0za4cvk8.n0-y.js} +1 -1
  15. package/out/_next/static/chunks/17y2walb2um9w.js +1 -0
  16. package/out/_not-found/__next._full.txt +11 -11
  17. package/out/_not-found/__next._head.txt +4 -4
  18. package/out/_not-found/__next._index.txt +6 -6
  19. package/out/_not-found/__next._not-found.__PAGE__.txt +2 -2
  20. package/out/_not-found/__next._not-found.txt +3 -3
  21. package/out/_not-found/__next._tree.txt +2 -2
  22. package/out/_not-found.html +1 -1
  23. package/out/_not-found.txt +11 -11
  24. package/out/app-shell/__next._full.txt +11 -11
  25. package/out/app-shell/__next._head.txt +4 -4
  26. package/out/app-shell/__next._index.txt +6 -6
  27. package/out/app-shell/__next._tree.txt +2 -2
  28. package/out/app-shell/__next.app-shell.__PAGE__.txt +2 -2
  29. package/out/app-shell/__next.app-shell.txt +3 -3
  30. package/out/app-shell.html +1 -1
  31. package/out/app-shell.txt +11 -11
  32. package/out/index.html +1 -1
  33. package/out/index.txt +12 -12
  34. package/out/project/_/__next._full.txt +12 -12
  35. package/out/project/_/__next._head.txt +4 -4
  36. package/out/project/_/__next._index.txt +6 -6
  37. package/out/project/_/__next._tree.txt +2 -2
  38. package/out/project/_/__next.project.$d$id.__PAGE__.txt +3 -3
  39. package/out/project/_/__next.project.$d$id.txt +3 -3
  40. package/out/project/_/__next.project.txt +3 -3
  41. package/out/project/_/memory/__next._full.txt +12 -12
  42. package/out/project/_/memory/__next._head.txt +4 -4
  43. package/out/project/_/memory/__next._index.txt +6 -6
  44. package/out/project/_/memory/__next._tree.txt +2 -2
  45. package/out/project/_/memory/__next.project.$d$id.memory.__PAGE__.txt +3 -3
  46. package/out/project/_/memory/__next.project.$d$id.memory.txt +3 -3
  47. package/out/project/_/memory/__next.project.$d$id.txt +3 -3
  48. package/out/project/_/memory/__next.project.txt +3 -3
  49. package/out/project/_/memory.html +1 -1
  50. package/out/project/_/memory.txt +12 -12
  51. package/out/project/_/queue/__next._full.txt +12 -12
  52. package/out/project/_/queue/__next._head.txt +4 -4
  53. package/out/project/_/queue/__next._index.txt +6 -6
  54. package/out/project/_/queue/__next._tree.txt +2 -2
  55. package/out/project/_/queue/__next.project.$d$id.queue.__PAGE__.txt +3 -3
  56. package/out/project/_/queue/__next.project.$d$id.queue.txt +3 -3
  57. package/out/project/_/queue/__next.project.$d$id.txt +3 -3
  58. package/out/project/_/queue/__next.project.txt +3 -3
  59. package/out/project/_/queue.html +1 -1
  60. package/out/project/_/queue.txt +12 -12
  61. package/out/project/_.html +1 -1
  62. package/out/project/_.txt +12 -12
  63. package/out/settings/__next._full.txt +12 -12
  64. package/out/settings/__next._head.txt +4 -4
  65. package/out/settings/__next._index.txt +6 -6
  66. package/out/settings/__next._tree.txt +2 -2
  67. package/out/settings/__next.settings.__PAGE__.txt +3 -3
  68. package/out/settings/__next.settings.txt +3 -3
  69. package/out/settings.html +1 -1
  70. package/out/settings.txt +12 -12
  71. package/out/setup/__next._full.txt +12 -12
  72. package/out/setup/__next._head.txt +4 -4
  73. package/out/setup/__next._index.txt +6 -6
  74. package/out/setup/__next._tree.txt +2 -2
  75. package/out/setup/__next.setup.__PAGE__.txt +3 -3
  76. package/out/setup/__next.setup.txt +3 -3
  77. package/out/setup.html +1 -1
  78. package/out/setup.txt +12 -12
  79. package/package.json +5 -2
  80. package/server/index.js +274 -12
  81. package/server/queue-watcher.js +47 -10
  82. package/server/queue-watcher.test.js +64 -0
  83. package/server/routes.batchProgress.test.js +94 -0
  84. package/server/routes.js +752 -33
  85. package/server/routes.parseActiveBatch.test.js +88 -0
  86. package/server/routes.telegramBridge.test.js +70 -0
  87. package/templates/CLAUDE.md +0 -1
  88. package/out/_next/static/chunks/06mbme.sc_26-.css +0 -2
  89. package/out/_next/static/chunks/0caq73v0knw_w.js +0 -1
  90. package/out/_next/static/chunks/0omuxbg.tg-il.js +0 -1
  91. /package/out/_next/static/{na3L7KeOGKGsbamYVibRj → OzDK1Fplm2eUu23bzILlU}/_buildManifest.js +0 -0
  92. /package/out/_next/static/{na3L7KeOGKGsbamYVibRj → OzDK1Fplm2eUu23bzILlU}/_clientMiddlewareManifest.js +0 -0
  93. /package/out/_next/static/{na3L7KeOGKGsbamYVibRj → OzDK1Fplm2eUu23bzILlU}/_ssgManifest.js +0 -0
package/out/setup.txt CHANGED
@@ -1,20 +1,20 @@
1
1
  1:"$Sreact.fragment"
2
- 2:I[34852,["/_next/static/chunks/0e~ue9ca5zrep.js","/_next/static/chunks/0ox7p_szjhn69.js"],"default"]
3
- 3:I[86081,["/_next/static/chunks/0e~ue9ca5zrep.js","/_next/static/chunks/0ox7p_szjhn69.js"],"default"]
4
- 4:I[12527,["/_next/static/chunks/0e~ue9ca5zrep.js","/_next/static/chunks/0ox7p_szjhn69.js"],"default"]
5
- 5:I[59763,["/_next/static/chunks/0e~ue9ca5zrep.js","/_next/static/chunks/0ox7p_szjhn69.js"],"default"]
6
- 6:I[64618,["/_next/static/chunks/0e~ue9ca5zrep.js","/_next/static/chunks/0ox7p_szjhn69.js","/_next/static/chunks/0caq73v0knw_w.js"],"default"]
7
- 7:I[11717,["/_next/static/chunks/0e~ue9ca5zrep.js","/_next/static/chunks/0ox7p_szjhn69.js"],"OutletBoundary"]
2
+ 2:I[34852,["/_next/static/chunks/0za4cvk8.n0-y.js","/_next/static/chunks/0ox7p_szjhn69.js"],"default"]
3
+ 3:I[86081,["/_next/static/chunks/0za4cvk8.n0-y.js","/_next/static/chunks/0ox7p_szjhn69.js"],"default"]
4
+ 4:I[12527,["/_next/static/chunks/0za4cvk8.n0-y.js","/_next/static/chunks/0ox7p_szjhn69.js"],"default"]
5
+ 5:I[59763,["/_next/static/chunks/0za4cvk8.n0-y.js","/_next/static/chunks/0ox7p_szjhn69.js"],"default"]
6
+ 6:I[64618,["/_next/static/chunks/0za4cvk8.n0-y.js","/_next/static/chunks/0ox7p_szjhn69.js","/_next/static/chunks/084lff9v4p_vh.js"],"default"]
7
+ 7:I[11717,["/_next/static/chunks/0za4cvk8.n0-y.js","/_next/static/chunks/0ox7p_szjhn69.js"],"OutletBoundary"]
8
8
  8:"$Sreact.suspense"
9
- b:I[11717,["/_next/static/chunks/0e~ue9ca5zrep.js","/_next/static/chunks/0ox7p_szjhn69.js"],"ViewportBoundary"]
10
- d:I[11717,["/_next/static/chunks/0e~ue9ca5zrep.js","/_next/static/chunks/0ox7p_szjhn69.js"],"MetadataBoundary"]
11
- f:I[92243,["/_next/static/chunks/0e~ue9ca5zrep.js","/_next/static/chunks/0ox7p_szjhn69.js"],"default",1]
12
- :HL["/_next/static/chunks/06mbme.sc_26-.css","style"]
9
+ b:I[11717,["/_next/static/chunks/0za4cvk8.n0-y.js","/_next/static/chunks/0ox7p_szjhn69.js"],"ViewportBoundary"]
10
+ d:I[11717,["/_next/static/chunks/0za4cvk8.n0-y.js","/_next/static/chunks/0ox7p_szjhn69.js"],"MetadataBoundary"]
11
+ f:I[92243,["/_next/static/chunks/0za4cvk8.n0-y.js","/_next/static/chunks/0ox7p_szjhn69.js"],"default",1]
12
+ :HL["/_next/static/chunks/05.po0c1knrbu.css","style"]
13
13
  :HL["/_next/static/media/797e433ab948586e-s.p.0.q-h669a_dqa.woff2","font",{"crossOrigin":"","type":"font/woff2"}]
14
- 0:{"P":null,"c":["","setup"],"q":"","i":false,"f":[[["",{"children":["setup",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",16],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/06mbme.sc_26-.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/_next/static/chunks/0e~ue9ca5zrep.js","async":true,"nonce":"$undefined"}],["$","script","script-1",{"src":"/_next/static/chunks/0ox7p_szjhn69.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"en","className":"geist_mono_8d43a2aa-module__8Li5zG__variable h-full","children":["$","body",null,{"className":"h-full flex flex-col","children":[["$","$L2",null,{}],["$","div",null,{"className":"flex flex-1 min-h-0","children":[["$","$L3",null,{}],["$","main",null,{"className":"flex-1 min-w-0 overflow-auto","children":["$","$L4",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}]]}]]}]}]]}],{"children":[["$","$1","c",{"children":[null,["$","$L4",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]]}],{"children":[["$","$1","c",{"children":[["$","$L6",null,{}],[["$","script","script-0",{"src":"/_next/static/chunks/0caq73v0knw_w.js","async":true,"nonce":"$undefined"}]],["$","$L7",null,{"children":["$","$8",null,{"name":"Next.MetadataOutlet","children":"$@9"}]}]]}],{},null,false,null]},null,false,"$@a"]},null,false,null],["$","$1","h",{"children":[null,["$","$Lb",null,{"children":"$Lc"}],["$","div",null,{"hidden":true,"children":["$","$Ld",null,{"children":["$","$8",null,{"name":"Next.Metadata","children":"$Le"}]}]}],["$","meta",null,{"name":"next-size-adjust","content":""}]]}],false]],"m":"$undefined","G":["$f",[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/06mbme.sc_26-.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]]],"S":true,"h":null,"s":"$undefined","l":"$undefined","p":"$undefined","d":"$undefined","b":"na3L7KeOGKGsbamYVibRj"}
14
+ 0:{"P":null,"c":["","setup"],"q":"","i":false,"f":[[["",{"children":["setup",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",16],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/05.po0c1knrbu.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/_next/static/chunks/0za4cvk8.n0-y.js","async":true,"nonce":"$undefined"}],["$","script","script-1",{"src":"/_next/static/chunks/0ox7p_szjhn69.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"en","className":"geist_mono_8d43a2aa-module__8Li5zG__variable h-full","children":["$","body",null,{"className":"h-full flex flex-col","children":[["$","$L2",null,{}],["$","div",null,{"className":"flex flex-1 min-h-0","children":[["$","$L3",null,{}],["$","main",null,{"className":"flex-1 min-w-0 overflow-auto","children":["$","$L4",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}]]}]]}]}]]}],{"children":[["$","$1","c",{"children":[null,["$","$L4",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]]}],{"children":[["$","$1","c",{"children":[["$","$L6",null,{}],[["$","script","script-0",{"src":"/_next/static/chunks/084lff9v4p_vh.js","async":true,"nonce":"$undefined"}]],["$","$L7",null,{"children":["$","$8",null,{"name":"Next.MetadataOutlet","children":"$@9"}]}]]}],{},null,false,null]},null,false,"$@a"]},null,false,null],["$","$1","h",{"children":[null,["$","$Lb",null,{"children":"$Lc"}],["$","div",null,{"hidden":true,"children":["$","$Ld",null,{"children":["$","$8",null,{"name":"Next.Metadata","children":"$Le"}]}]}],["$","meta",null,{"name":"next-size-adjust","content":""}]]}],false]],"m":"$undefined","G":["$f",[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/05.po0c1knrbu.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]]],"S":true,"h":null,"s":"$undefined","l":"$undefined","p":"$undefined","d":"$undefined","b":"OzDK1Fplm2eUu23bzILlU"}
15
15
  10:[]
16
16
  a:"$W10"
17
17
  c:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
18
- 11:I[80070,["/_next/static/chunks/0e~ue9ca5zrep.js","/_next/static/chunks/0ox7p_szjhn69.js"],"IconMark"]
18
+ 11:I[80070,["/_next/static/chunks/0za4cvk8.n0-y.js","/_next/static/chunks/0ox7p_szjhn69.js"],"IconMark"]
19
19
  9:null
20
20
  e:[["$","title","0",{"children":"QuadWork"}],["$","meta","1",{"name":"description","content":"Unified dashboard for multi-agent coding teams"}],["$","link","2",{"rel":"icon","href":"/favicon.ico?favicon.0x3dzn~oxb6tn.ico","sizes":"256x256","type":"image/x-icon"}],["$","$L11","3",{}]]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quadwork",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "Unified dashboard for multi-agent coding teams — 4 AI agents, one terminal",
5
5
  "bin": {
6
6
  "quadwork": "./bin/quadwork.js"
@@ -17,7 +17,10 @@
17
17
  "start": "node server/",
18
18
  "lint": "eslint",
19
19
  "server": "node server/",
20
- "prepack": "npm run build"
20
+ "prepack": "npm run build",
21
+ "release:patch": "npm version patch && git push origin main --follow-tags && VERSION=$(node -p 'require(\"./package.json\").version') && gh release create \"v$VERSION\" --generate-notes --latest && npm publish",
22
+ "release:minor": "npm version minor && git push origin main --follow-tags && VERSION=$(node -p 'require(\"./package.json\").version') && gh release create \"v$VERSION\" --generate-notes --latest && npm publish",
23
+ "release:major": "npm version major && git push origin main --follow-tags && VERSION=$(node -p 'require(\"./package.json\").version') && gh release create \"v$VERSION\" --generate-notes --latest && npm publish"
21
24
  },
22
25
  "engines": {
23
26
  "node": ">=20"
package/server/index.js CHANGED
@@ -312,6 +312,32 @@ async function buildAgentArgs(projectId, agentId) {
312
312
  if (flags) args.push(...flags);
313
313
  }
314
314
 
315
+ // #343: per-agent model + reasoning effort overrides. Persist in
316
+ // project.agents[agentId].{model,reasoning_effort} via the
317
+ // dashboard Agent Models widget. When unset, fall back to the
318
+ // CLI's own default so existing projects without overrides keep
319
+ // their current behavior.
320
+ //
321
+ // Codex: -c model="<slug>" / -c model_reasoning_effort="<level>"
322
+ // reasoning levels: minimal | low | medium | high (xhigh is
323
+ // deliberately NOT offered — it's the capacity-failure hot
324
+ // spot #343 was filed for).
325
+ // Claude: --model <slug>
326
+ // reasoning_effort is not wired for Claude — Anthropic's CLI
327
+ // doesn't expose an equivalent flag.
328
+ if (cliBase === "codex") {
329
+ if (agentCfg.model && typeof agentCfg.model === "string") {
330
+ args.push("-c", `model="${agentCfg.model}"`);
331
+ }
332
+ if (agentCfg.reasoning_effort && typeof agentCfg.reasoning_effort === "string") {
333
+ args.push("-c", `model_reasoning_effort="${agentCfg.reasoning_effort}"`);
334
+ }
335
+ } else if (cliBase === "claude") {
336
+ if (agentCfg.model && typeof agentCfg.model === "string") {
337
+ args.push("--model", agentCfg.model);
338
+ }
339
+ }
340
+
315
341
  // MCP config injection
316
342
  const mcpHttpPort = project.mcp_http_port;
317
343
  const token = project.agentchattr_token;
@@ -664,6 +690,55 @@ app.get("/api/agents", (_req, res) => {
664
690
  res.json(agents);
665
691
  });
666
692
 
693
+ // #424 / quadwork#304: best-effort auto-snapshot of chat history
694
+ // before any AgentChattr restart. Defense-in-depth against
695
+ // destructive ops like /clear that rewrite AC's JSONL log in place
696
+ // — per #303 the log itself IS persistent across normal restarts,
697
+ // so the snapshot's job is to give the operator a point-in-time
698
+ // rollback if the log gets clobbered, not to prevent history loss
699
+ // on ordinary lifecycle events.
700
+ //
701
+ // Snapshot contents = the same envelope GET /api/project-history
702
+ // returns, so an operator (or a future "restore" button) can feed
703
+ // the file straight into POST /api/project-history for replay.
704
+ const HISTORY_SNAPSHOT_LIMIT = 5;
705
+
706
+ async function snapshotProjectHistory(projectId) {
707
+ try {
708
+ const snapDir = path.join(require("os").homedir(), ".quadwork", projectId, "history-snapshots");
709
+ if (!fs.existsSync(snapDir)) fs.mkdirSync(snapDir, { recursive: true });
710
+ const res = await fetch(`http://127.0.0.1:${PORT}/api/project-history?project=${encodeURIComponent(projectId)}`, {
711
+ signal: AbortSignal.timeout(30000),
712
+ });
713
+ if (!res.ok) {
714
+ console.warn(`[snapshot] ${projectId} history fetch returned ${res.status}; skipping snapshot`);
715
+ return false;
716
+ }
717
+ const text = await res.text();
718
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
719
+ const outPath = path.join(snapDir, `${stamp}.json`);
720
+ fs.writeFileSync(outPath, text);
721
+ console.log(`[snapshot] ${projectId} → ${outPath}`);
722
+ // Prune to the newest HISTORY_SNAPSHOT_LIMIT files so the
723
+ // directory can't grow unbounded across weeks of restarts.
724
+ try {
725
+ const entries = fs.readdirSync(snapDir)
726
+ .filter((f) => f.endsWith(".json"))
727
+ .map((f) => ({ f, t: fs.statSync(path.join(snapDir, f)).mtimeMs }))
728
+ .sort((a, b) => b.t - a.t);
729
+ for (const old of entries.slice(HISTORY_SNAPSHOT_LIMIT)) {
730
+ try { fs.unlinkSync(path.join(snapDir, old.f)); } catch {}
731
+ }
732
+ } catch {
733
+ // non-fatal — stale snapshots just linger
734
+ }
735
+ return true;
736
+ } catch (err) {
737
+ console.warn(`[snapshot] ${projectId} snapshot failed: ${err.message || err}`);
738
+ return false;
739
+ }
740
+ }
741
+
667
742
  // Per-project AgentChattr lifecycle: /api/agentchattr/:project/:action
668
743
  // Backward compat: /api/agentchattr/:action uses first project
669
744
  async function handleAgentChattr(req, res) {
@@ -785,6 +860,18 @@ async function handleAgentChattr(req, res) {
785
860
  setProc({ process: null, state: "stopped", error: null });
786
861
  res.json({ ok: true, state: "stopped" });
787
862
  } else if (action === "restart") {
863
+ // #424 / quadwork#304: snapshot history before killing the
864
+ // process. Best-effort and non-blocking-on-failure so a flaky
865
+ // snapshot doesn't leave the operator unable to restart AC.
866
+ await snapshotProjectHistory(projectId).catch(() => {});
867
+ // #424 / quadwork#304 Phase 3: latch the opt-in BEFORE the
868
+ // spawn so a restart that itself clears the flag can't starve
869
+ // the auto-restore. We capture the snapshot filename we just
870
+ // wrote + the project's auto_restore_after_restart flag and
871
+ // replay it in the post-spawn tick below if both are set.
872
+ const preRestartCfg = readConfig();
873
+ const preRestartProject = preRestartCfg.projects?.find((p) => p.id === projectId);
874
+ const shouldAutoRestore = !!(preRestartProject && preRestartProject.auto_restore_after_restart);
788
875
  const proc = getProc();
789
876
  if (proc.process) {
790
877
  try { proc.process.kill("SIGTERM"); } catch {}
@@ -799,6 +886,30 @@ async function handleAgentChattr(req, res) {
799
886
  }
800
887
  // Sync token after AgentChattr restarts
801
888
  setTimeout(() => syncChattrToken(projectId), 2000);
889
+ // #424 / quadwork#304 Phase 3: optional auto-restore.
890
+ // Fire the restore 3s after spawn so AC's ws is ready.
891
+ // Best-effort: never blocks the restart response or
892
+ // rolls back on error.
893
+ if (shouldAutoRestore) {
894
+ setTimeout(async () => {
895
+ try {
896
+ const snapDir = path.join(require("os").homedir(), ".quadwork", projectId, "history-snapshots");
897
+ if (!fs.existsSync(snapDir)) return;
898
+ const newest = fs.readdirSync(snapDir)
899
+ .filter((f) => f.endsWith(".json"))
900
+ .map((f) => ({ f, t: fs.statSync(path.join(snapDir, f)).mtimeMs }))
901
+ .sort((a, b) => b.t - a.t)[0];
902
+ if (!newest) return;
903
+ const r = await fetch(`http://127.0.0.1:${PORT}/api/project-history/restore?project=${encodeURIComponent(projectId)}&name=${encodeURIComponent(newest.f)}`, {
904
+ method: "POST",
905
+ });
906
+ if (r.ok) console.log(`[snapshot] ${projectId} auto-restored ${newest.f}`);
907
+ else console.warn(`[snapshot] ${projectId} auto-restore returned ${r.status}`);
908
+ } catch (err) {
909
+ console.warn(`[snapshot] ${projectId} auto-restore failed: ${err.message || err}`);
910
+ }
911
+ }, 3000);
912
+ }
802
913
  res.json({ ok: true, state: "running", pid: child.pid });
803
914
  } catch (err) {
804
915
  setProc({ process: null, state: "error", error: err.message });
@@ -814,7 +925,16 @@ async function handleAgentChattr(req, res) {
814
925
  try {
815
926
  const { execSync } = require("child_process");
816
927
 
817
- // Stop running process before pulling
928
+ // Stop running process before pulling. Snapshot first so a
929
+ // botched git pull can still be rolled back from disk.
930
+ // #424 / quadwork#304: best-effort.
931
+ await snapshotProjectHistory(projectId).catch(() => {});
932
+ // Latch the auto-restore opt-in BEFORE stop, same as the
933
+ // explicit restart branch above — a config mutation during
934
+ // the git pull shouldn't starve the replay.
935
+ const updateCfgPre = readConfig();
936
+ const updateProjectPre = updateCfgPre.projects?.find((p) => p.id === projectId);
937
+ const updateShouldAutoRestore = !!(updateProjectPre && updateProjectPre.auto_restore_after_restart);
818
938
  const proc = getProc();
819
939
  const wasRunning = proc.process && proc.state === "running";
820
940
  if (wasRunning) {
@@ -839,6 +959,30 @@ async function handleAgentChattr(req, res) {
839
959
  restarted = !!child;
840
960
  if (child) {
841
961
  setTimeout(() => syncChattrToken(projectId).catch(() => {}), 2000);
962
+ // #424 / quadwork#304 Phase 3: auto-restore after an
963
+ // update-triggered restart too (t2a re-review). Same
964
+ //3s wait + newest-snapshot-by-mtime path as the explicit
965
+ // restart branch, using the pre-stop latched opt-in.
966
+ if (updateShouldAutoRestore) {
967
+ setTimeout(async () => {
968
+ try {
969
+ const snapDir = path.join(require("os").homedir(), ".quadwork", projectId, "history-snapshots");
970
+ if (!fs.existsSync(snapDir)) return;
971
+ const newest = fs.readdirSync(snapDir)
972
+ .filter((f) => f.endsWith(".json"))
973
+ .map((f) => ({ f, t: fs.statSync(path.join(snapDir, f)).mtimeMs }))
974
+ .sort((a, b) => b.t - a.t)[0];
975
+ if (!newest) return;
976
+ const r = await fetch(`http://127.0.0.1:${PORT}/api/project-history/restore?project=${encodeURIComponent(projectId)}&name=${encodeURIComponent(newest.f)}`, {
977
+ method: "POST",
978
+ });
979
+ if (r.ok) console.log(`[snapshot] ${projectId} auto-restored ${newest.f} after update`);
980
+ else console.warn(`[snapshot] ${projectId} post-update auto-restore returned ${r.status}`);
981
+ } catch (err) {
982
+ console.warn(`[snapshot] ${projectId} post-update auto-restore failed: ${err.message || err}`);
983
+ }
984
+ }, 3000);
985
+ }
842
986
  }
843
987
  }
844
988
 
@@ -1086,7 +1230,12 @@ function stopTrigger(project) {
1086
1230
 
1087
1231
  app.post("/api/triggers/:project/start", (req, res) => {
1088
1232
  const { project } = req.params;
1089
- const { interval, duration, message, sendImmediately } = req.body || {};
1233
+ // #418 / quadwork#306: sendImmediately was an always-true
1234
+ // "Send Message and Start Trigger" flag from #210; operators
1235
+ // asked for a pure scheduler ("Start Trigger" — wait for the
1236
+ // first interval). The field is ignored here; the send-now
1237
+ // endpoint below still exists for the explicit one-shot path.
1238
+ const { interval, duration, message } = req.body || {};
1090
1239
  const ms = (interval || 30) * 60 * 1000;
1091
1240
  const durationMs = duration ? duration * 60 * 1000 : 0; // duration in minutes, 0 = indefinite
1092
1241
 
@@ -1113,16 +1262,12 @@ app.post("/api/triggers/:project/start", (req, res) => {
1113
1262
  if (existing.durationTimer) clearTimeout(existing.durationTimer);
1114
1263
  }
1115
1264
 
1116
- // #210: the Scheduled Trigger widget's "Send Message and Start
1117
- // Trigger" button expects an immediate send, not the first fire
1118
- // one interval in the future. setInterval won't do that on its
1119
- // own, so trigger a one-shot send when sendImmediately is true.
1120
- if (sendImmediately) {
1121
- // Don't await keep the response fast. sendTriggerMessage logs
1122
- // its own errors and updates lastError on the trigger info.
1123
- sendTriggerMessage(project).catch(() => {});
1124
- }
1125
-
1265
+ // #418 / quadwork#306: no immediate fire the first send happens
1266
+ // at T + interval via the setInterval below. Operators set the
1267
+ // trigger up in advance of going afk and don't want it interrupting
1268
+ // whatever agents are currently mid-task. The explicit "send now"
1269
+ // path still lives at /api/triggers/:project/send-now for the
1270
+ // rare case an operator actually wants to kick things off.
1126
1271
  const timer = setInterval(() => sendTriggerMessage(project), ms);
1127
1272
  const expiresAt = durationMs > 0 ? Date.now() + durationMs : null;
1128
1273
 
@@ -1390,6 +1535,123 @@ function syncTriggersFromConfig() {
1390
1535
  }
1391
1536
  }
1392
1537
 
1538
+ // #422 / quadwork#310: auto-continue after loop guard.
1539
+ //
1540
+ // Per opted-in project, poll AC's /api/status every 10s. When we see
1541
+ // a false → true transition on `paused`, wait the configured delay
1542
+ // (default 30s) and POST /continue to /api/chat — same path the
1543
+ // operator would use manually. The delay gives a human a chance to
1544
+ // intervene on an actually-runaway loop, and acts as a soft rate
1545
+ // limit against pathological loops that would otherwise just loop
1546
+ // forever under an auto-continue.
1547
+ //
1548
+ // Detection is deliberately polling rather than a long-lived ws:
1549
+ // a ws subscription per project would complicate lifecycle and
1550
+ // reconnection, and 10s polling latency is acceptable when the
1551
+ // delay is tens of seconds. Skipping projects without the opt-in
1552
+ // keeps the poller cheap for single-project setups.
1553
+
1554
+ const _loopGuardPausedState = new Map(); // projectId -> { paused: bool, scheduled: Timeout? }
1555
+ const LOOP_GUARD_POLL_INTERVAL_MS = 10000;
1556
+
1557
+ async function checkLoopGuardPause(project) {
1558
+ if (!project || !project.auto_continue_loop_guard) return;
1559
+ const { url: base, token: sessionToken } = resolveProjectChattr(project.id);
1560
+ if (!base) return;
1561
+ let paused = false;
1562
+ try {
1563
+ const r = await fetch(`${base}/api/status`, {
1564
+ headers: sessionToken ? { "x-session-token": sessionToken } : {},
1565
+ signal: AbortSignal.timeout(5000),
1566
+ });
1567
+ if (!r.ok) return;
1568
+ const data = await r.json();
1569
+ paused = !!(data && data.paused);
1570
+ } catch {
1571
+ return;
1572
+ }
1573
+ const state = _loopGuardPausedState.get(project.id) || { paused: false, scheduled: null };
1574
+ // Transition false → true: schedule an auto-continue after the delay.
1575
+ if (paused && !state.paused && !state.scheduled) {
1576
+ const delaySec = Number.isFinite(project.auto_continue_delay_sec) && project.auto_continue_delay_sec >= 5
1577
+ ? project.auto_continue_delay_sec
1578
+ : 30;
1579
+ console.log(`[loop-guard] ${project.id} paused — auto-continue in ${delaySec}s`);
1580
+ state.scheduled = setTimeout(async () => {
1581
+ try {
1582
+ // Re-check the opt-in at fire time so a checkbox disable
1583
+ // mid-wait actually stops the auto-continue.
1584
+ const freshCfg = readConfig();
1585
+ const fresh = freshCfg.projects?.find((p) => p.id === project.id);
1586
+ if (!fresh || !fresh.auto_continue_loop_guard) {
1587
+ console.log(`[loop-guard] ${project.id} auto-continue cancelled (opt-in disabled during wait)`);
1588
+ } else {
1589
+ // Re-check the router's pause state at fire time too. The
1590
+ // 10s status poller may not have seen a manual operator
1591
+ // /continue yet when the delay window (5–9s) is shorter
1592
+ // than the poll interval — without this, a manual resume
1593
+ // inside a 5s wait would be followed by a stale auto
1594
+ // /continue that clobbers hop_count on an already-running
1595
+ // chain (router.continue_routing resets the counter
1596
+ // unconditionally). The re-check closes the race.
1597
+ let stillPaused = false;
1598
+ try {
1599
+ const { url: freshBase, token: freshToken } = resolveProjectChattr(project.id);
1600
+ if (freshBase) {
1601
+ const sr = await fetch(`${freshBase}/api/status`, {
1602
+ headers: freshToken ? { "x-session-token": freshToken } : {},
1603
+ signal: AbortSignal.timeout(5000),
1604
+ });
1605
+ if (sr.ok) {
1606
+ const sd = await sr.json();
1607
+ stillPaused = !!(sd && sd.paused);
1608
+ }
1609
+ }
1610
+ } catch {
1611
+ // Status re-check failed — fall back to "don't fire".
1612
+ // Stuck pause will still be caught on the next 10s tick.
1613
+ }
1614
+ if (!stillPaused) {
1615
+ console.log(`[loop-guard] ${project.id} auto-continue cancelled (router already resumed)`);
1616
+ } else {
1617
+ const res = await fetch(`http://127.0.0.1:${PORT}/api/chat?project=${encodeURIComponent(project.id)}`, {
1618
+ method: "POST",
1619
+ headers: { "Content-Type": "application/json" },
1620
+ body: JSON.stringify({ text: "/continue", channel: "general" }),
1621
+ });
1622
+ if (res.ok) console.log(`[loop-guard] ${project.id} auto-continued`);
1623
+ else console.warn(`[loop-guard] ${project.id} auto-continue POST returned ${res.status}`);
1624
+ }
1625
+ }
1626
+ } catch (err) {
1627
+ console.warn(`[loop-guard] ${project.id} auto-continue failed: ${err.message || err}`);
1628
+ }
1629
+ const s2 = _loopGuardPausedState.get(project.id);
1630
+ if (s2) s2.scheduled = null;
1631
+ }, delaySec * 1000);
1632
+ }
1633
+ // Transition true → false: clear any pending timer.
1634
+ if (!paused && state.paused && state.scheduled) {
1635
+ clearTimeout(state.scheduled);
1636
+ state.scheduled = null;
1637
+ }
1638
+ state.paused = paused;
1639
+ _loopGuardPausedState.set(project.id, state);
1640
+ }
1641
+
1642
+ function runLoopGuardPollingTick() {
1643
+ try {
1644
+ const cfg = readConfig();
1645
+ for (const p of (cfg.projects || [])) {
1646
+ if (p && p.auto_continue_loop_guard) checkLoopGuardPause(p);
1647
+ }
1648
+ } catch {
1649
+ // config unreadable — next tick will retry
1650
+ }
1651
+ }
1652
+
1653
+ setInterval(runLoopGuardPollingTick, LOOP_GUARD_POLL_INTERVAL_MS);
1654
+
1393
1655
  // --- Start ---
1394
1656
 
1395
1657
  server.listen(PORT, "127.0.0.1", () => {
@@ -11,8 +11,19 @@
11
11
  * Reference: /Users/cho/Projects/agentchattr/wrapper.py lines 438-541
12
12
  * (`_queue_watcher`). Polling (not fs.watch) is intentional: matches
13
13
  * wrapper.py's behavior and avoids the cross-platform fs.watch
14
- * footguns. The role/rules/identity-hint additions from wrapper.py
15
- * lines 501-528 are intentionally out of scope for v1 per the issue.
14
+ * footguns.
15
+ *
16
+ * #342 / quadwork#342: the v1 prompt intentionally omitted the
17
+ * identity hints from wrapper.py lines 501-528, which broke
18
+ * Claude Code agent sessions. Claude's default self-concept is
19
+ * `@claude`, so a bare `mcp read #general - you were mentioned`
20
+ * causes chat_read(sender: "claude") and a filter on `@claude`
21
+ * mentions — both wrong when the agent is actually @dev /
22
+ * @reviewerN. Codex doesn't trip the same way because its init
23
+ * path already claims identity. The fix here is to scope the
24
+ * wrapper.py additions to identity only: the injected prompt
25
+ * now explicitly names the agent slug and tells the agent which
26
+ * sender to use on chat_read and which mentions to look for.
16
27
  */
17
28
 
18
29
  const fs = require("fs");
@@ -20,6 +31,38 @@ const path = require("path");
20
31
 
21
32
  const POLL_INTERVAL_MS = 1000;
22
33
 
34
+ /**
35
+ * Pure helper: build the injected prompt text for a given agent
36
+ * slug + trigger shape. Exported so it can be unit-tested without
37
+ * a PTY or a filesystem queue. Priority matches the tick() call
38
+ * site below: customPrompt > jobId > channel.
39
+ *
40
+ * agentName is expected to be the registered agent slug such as
41
+ * `dev`, `head`, `reviewer1`, `reviewer2`. The helper does not
42
+ * validate — upstream already controls who may register.
43
+ */
44
+ function buildInjectionPrompt(agentName, { channel, jobId, customPrompt } = {}) {
45
+ if (customPrompt && typeof customPrompt === "string" && customPrompt.trim()) {
46
+ // Operator-supplied prompts already control the identity
47
+ // wording; leave them alone.
48
+ return customPrompt.trim();
49
+ }
50
+ if (jobId) {
51
+ return (
52
+ `You are @${agentName} in this AgentChattr instance. ` +
53
+ `mcp read job_id=${jobId} with sender: "${agentName}" — ` +
54
+ `you (@${agentName}) were mentioned in a job thread, take appropriate action.`
55
+ );
56
+ }
57
+ const ch = channel || "general";
58
+ return (
59
+ `You are @${agentName} in this AgentChattr instance. ` +
60
+ `mcp read #${ch} with sender: "${agentName}" — ` +
61
+ `look for @${agentName} mentions (NOT @claude). ` +
62
+ `You were mentioned, take appropriate action.`
63
+ );
64
+ }
65
+
23
66
  /**
24
67
  * Start polling `{dataDir}/{agentName}_queue.jsonl`. When non-empty,
25
68
  * read all lines, truncate the file (atomic-ish claim — same race the
@@ -78,14 +121,7 @@ function startQueueWatcher(dataDir, agentName, ptyTerm) {
78
121
  }
79
122
  if (!hasTrigger) return;
80
123
 
81
- let prompt;
82
- if (customPrompt) {
83
- prompt = customPrompt;
84
- } else if (jobId) {
85
- prompt = `mcp read job_id=${jobId} - you were mentioned in a job thread, take appropriate action`;
86
- } else {
87
- prompt = `mcp read #${channel} - you were mentioned, take appropriate action`;
88
- }
124
+ const prompt = buildInjectionPrompt(agentName, { channel, jobId, customPrompt });
89
125
 
90
126
  // Flatten newlines: multi-line writes trigger paste detection in
91
127
  // Claude Code (shows "[Pasted text +N]") and can break injection
@@ -122,4 +158,5 @@ function stopQueueWatcher(handle) {
122
158
  module.exports = {
123
159
  startQueueWatcher,
124
160
  stopQueueWatcher,
161
+ buildInjectionPrompt,
125
162
  };
@@ -0,0 +1,64 @@
1
+ // #342 / quadwork#342: unit test for the identity-aware prompt
2
+ // builder. No test runner is wired up in this repo, so this file
3
+ // is a plain node:assert script — run it with `node server/queue-watcher.test.js`
4
+ // and it exits non-zero on any failure.
5
+
6
+ const assert = require("node:assert/strict");
7
+ const { buildInjectionPrompt } = require("./queue-watcher");
8
+
9
+ const DEFAULT_AGENT_SLUGS = ["dev", "head", "reviewer1", "reviewer2"];
10
+
11
+ // 1) Channel prompt — each of the 4 default slugs must:
12
+ // - name the agent with @<slug>
13
+ // - pass sender: "<slug>" explicitly
14
+ // - tell the agent to look for @<slug> mentions (NOT @claude)
15
+ for (const slug of DEFAULT_AGENT_SLUGS) {
16
+ const p = buildInjectionPrompt(slug, { channel: "general" });
17
+ assert.match(p, new RegExp(`You are @${slug} `), `channel: names @${slug}`);
18
+ assert.match(p, new RegExp(`sender: "${slug}"`), `channel: sender string for ${slug}`);
19
+ assert.match(p, new RegExp(`@${slug} mentions`), `channel: @${slug} mention filter`);
20
+ assert.match(p, /NOT @claude/, `channel: explicit NOT @claude guard for ${slug}`);
21
+ assert.match(p, /#general/, `channel: channel name for ${slug}`);
22
+ }
23
+
24
+ // 2) Channel defaults to "general" when not provided.
25
+ {
26
+ const p = buildInjectionPrompt("dev", {});
27
+ assert.match(p, /#general/, "channel defaults to general");
28
+ }
29
+
30
+ // 3) Non-default channel is passed through.
31
+ {
32
+ const p = buildInjectionPrompt("dev", { channel: "batch-33" });
33
+ assert.match(p, /#batch-33/, "custom channel is used");
34
+ }
35
+
36
+ // 4) Job-thread prompt — each slug must:
37
+ // - name the agent with @<slug>
38
+ // - reference the job_id
39
+ // - pass sender: "<slug>" explicitly
40
+ for (const slug of DEFAULT_AGENT_SLUGS) {
41
+ const p = buildInjectionPrompt(slug, { jobId: "42" });
42
+ assert.match(p, new RegExp(`You are @${slug} `), `job: names @${slug}`);
43
+ assert.match(p, /job_id=42/, `job: job_id for ${slug}`);
44
+ assert.match(p, new RegExp(`sender: "${slug}"`), `job: sender string for ${slug}`);
45
+ }
46
+
47
+ // 5) customPrompt wins over channel and jobId and is returned as-is.
48
+ {
49
+ const p = buildInjectionPrompt("dev", {
50
+ channel: "general",
51
+ jobId: "99",
52
+ customPrompt: " do the thing ",
53
+ });
54
+ assert.equal(p, "do the thing", "customPrompt overrides + trims");
55
+ }
56
+
57
+ // 6) Blank customPrompt is ignored (falls through to channel/job path).
58
+ {
59
+ const p = buildInjectionPrompt("reviewer2", { customPrompt: " ", channel: "general" });
60
+ assert.match(p, /You are @reviewer2 /);
61
+ assert.match(p, /#general/);
62
+ }
63
+
64
+ console.log(`queue-watcher.test.js: all assertions passed (${DEFAULT_AGENT_SLUGS.length * 2 + 4} cases)`);