@setupsworks/office 0.0.0-stage → 0.7.5
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.
- package/LICENSE +21 -0
- package/README.md +111 -2
- package/bin/setups-works-office.mjs +50 -0
- package/dist/assets/CanvasPool-D8o5qooo.js +1 -0
- package/dist/assets/CanvasRenderer-6iBTvAyb.js +1 -0
- package/dist/assets/RenderTargetSystem-R9NN3Lf_.js +184 -0
- package/dist/assets/WebGLRenderer-BceHugHE.js +156 -0
- package/dist/assets/WebGPURenderer-B-kl5Onz.js +75 -0
- package/dist/assets/browserAll-CXCccwgI.js +14 -0
- package/dist/assets/canvasUtils-Cbq_vt2i.js +1 -0
- package/dist/assets/getTexelRangeRects-DY8vmJhl.js +195 -0
- package/dist/assets/index-ByCDDgCj.js +337 -0
- package/dist/assets/index-CmC5xWQz.css +1 -0
- package/dist/assets/inter-cyrillic-400-normal-HOLc17fK.woff +0 -0
- package/dist/assets/inter-cyrillic-400-normal-obahsSVq.woff2 +0 -0
- package/dist/assets/inter-cyrillic-500-normal-BasfLYem.woff2 +0 -0
- package/dist/assets/inter-cyrillic-500-normal-CxZf_p3X.woff +0 -0
- package/dist/assets/inter-cyrillic-600-normal-4D_pXhcN.woff +0 -0
- package/dist/assets/inter-cyrillic-600-normal-CWCymEST.woff2 +0 -0
- package/dist/assets/inter-cyrillic-700-normal-CjBOestx.woff2 +0 -0
- package/dist/assets/inter-cyrillic-700-normal-DrXBdSj3.woff +0 -0
- package/dist/assets/inter-cyrillic-ext-400-normal-BQZuk6qB.woff2 +0 -0
- package/dist/assets/inter-cyrillic-ext-400-normal-DQukG94-.woff +0 -0
- package/dist/assets/inter-cyrillic-ext-500-normal-B0yAr1jD.woff2 +0 -0
- package/dist/assets/inter-cyrillic-ext-500-normal-BmqWE9Dz.woff +0 -0
- package/dist/assets/inter-cyrillic-ext-600-normal-Bcila6Z-.woff +0 -0
- package/dist/assets/inter-cyrillic-ext-600-normal-Dfes3d0z.woff2 +0 -0
- package/dist/assets/inter-cyrillic-ext-700-normal-BjwYoWNd.woff2 +0 -0
- package/dist/assets/inter-cyrillic-ext-700-normal-LO58E6JB.woff +0 -0
- package/dist/assets/inter-greek-400-normal-B4URO6DV.woff2 +0 -0
- package/dist/assets/inter-greek-400-normal-q2sYcFCs.woff +0 -0
- package/dist/assets/inter-greek-500-normal-BIZE56-Y.woff2 +0 -0
- package/dist/assets/inter-greek-500-normal-Xzm54t5V.woff +0 -0
- package/dist/assets/inter-greek-600-normal-BZpKdvQh.woff +0 -0
- package/dist/assets/inter-greek-600-normal-plRanbMR.woff2 +0 -0
- package/dist/assets/inter-greek-700-normal-BUv2fZ6O.woff +0 -0
- package/dist/assets/inter-greek-700-normal-C3JjAnD8.woff2 +0 -0
- package/dist/assets/inter-greek-ext-400-normal-DGGRlc-M.woff2 +0 -0
- package/dist/assets/inter-greek-ext-400-normal-KugGGMne.woff +0 -0
- package/dist/assets/inter-greek-ext-500-normal-2j5mBUwD.woff +0 -0
- package/dist/assets/inter-greek-ext-500-normal-C4iEst2y.woff2 +0 -0
- package/dist/assets/inter-greek-ext-600-normal-B8X0CLgF.woff +0 -0
- package/dist/assets/inter-greek-ext-600-normal-DRtmH8MT.woff2 +0 -0
- package/dist/assets/inter-greek-ext-700-normal-BoQ6DsYi.woff +0 -0
- package/dist/assets/inter-greek-ext-700-normal-qfdV9bQt.woff2 +0 -0
- package/dist/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
- package/dist/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
- package/dist/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
- package/dist/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
- package/dist/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
- package/dist/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
- package/dist/assets/inter-latin-700-normal-BLAVimhd.woff +0 -0
- package/dist/assets/inter-latin-700-normal-Yt3aPRUw.woff2 +0 -0
- package/dist/assets/inter-latin-ext-400-normal-77YHD8bZ.woff +0 -0
- package/dist/assets/inter-latin-ext-400-normal-C1nco2VV.woff2 +0 -0
- package/dist/assets/inter-latin-ext-500-normal-BxGbmqWO.woff +0 -0
- package/dist/assets/inter-latin-ext-500-normal-CV4jyFjo.woff2 +0 -0
- package/dist/assets/inter-latin-ext-600-normal-CIVaiw4L.woff +0 -0
- package/dist/assets/inter-latin-ext-600-normal-D2bJ5OIk.woff2 +0 -0
- package/dist/assets/inter-latin-ext-700-normal-Ca8adRJv.woff2 +0 -0
- package/dist/assets/inter-latin-ext-700-normal-TidjK2hL.woff +0 -0
- package/dist/assets/inter-vietnamese-400-normal-Bbgyi5SW.woff +0 -0
- package/dist/assets/inter-vietnamese-400-normal-DMkecbls.woff2 +0 -0
- package/dist/assets/inter-vietnamese-500-normal-DOriooB6.woff2 +0 -0
- package/dist/assets/inter-vietnamese-500-normal-mJboJaSs.woff +0 -0
- package/dist/assets/inter-vietnamese-600-normal-BuLX-rYi.woff +0 -0
- package/dist/assets/inter-vietnamese-600-normal-Cc8MFFhd.woff2 +0 -0
- package/dist/assets/inter-vietnamese-700-normal-BZaoP0fm.woff +0 -0
- package/dist/assets/inter-vietnamese-700-normal-DlLaEgI2.woff2 +0 -0
- package/dist/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
- package/dist/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
- package/dist/assets/jetbrains-mono-cyrillic-500-normal-DJqRU3vO.woff +0 -0
- package/dist/assets/jetbrains-mono-cyrillic-500-normal-DmUKJPL_.woff2 +0 -0
- package/dist/assets/jetbrains-mono-cyrillic-700-normal-BWTpRfYl.woff2 +0 -0
- package/dist/assets/jetbrains-mono-cyrillic-700-normal-CEoEElIJ.woff +0 -0
- package/dist/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
- package/dist/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
- package/dist/assets/jetbrains-mono-greek-500-normal-D7SFKleX.woff +0 -0
- package/dist/assets/jetbrains-mono-greek-500-normal-JpySY46c.woff2 +0 -0
- package/dist/assets/jetbrains-mono-greek-700-normal-C6CZE3T8.woff2 +0 -0
- package/dist/assets/jetbrains-mono-greek-700-normal-DEigVDxa.woff +0 -0
- package/dist/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
- package/dist/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
- package/dist/assets/jetbrains-mono-latin-500-normal-BWZEU5yA.woff2 +0 -0
- package/dist/assets/jetbrains-mono-latin-500-normal-CJOVTJB7.woff +0 -0
- package/dist/assets/jetbrains-mono-latin-700-normal-BYuf6tUa.woff2 +0 -0
- package/dist/assets/jetbrains-mono-latin-700-normal-D3wTyLJW.woff +0 -0
- package/dist/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
- package/dist/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
- package/dist/assets/jetbrains-mono-latin-ext-500-normal-Cut-4mMH.woff2 +0 -0
- package/dist/assets/jetbrains-mono-latin-ext-500-normal-ckzbgY84.woff +0 -0
- package/dist/assets/jetbrains-mono-latin-ext-700-normal-CZipNAKV.woff2 +0 -0
- package/dist/assets/jetbrains-mono-latin-ext-700-normal-CxPITLHs.woff +0 -0
- package/dist/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
- package/dist/assets/jetbrains-mono-vietnamese-500-normal-DNRqzVM1.woff +0 -0
- package/dist/assets/jetbrains-mono-vietnamese-700-normal-BDLVIk2r.woff +0 -0
- package/dist/assets/pixelify-sans-cyrillic-400-normal-DfOZNMlW.woff +0 -0
- package/dist/assets/pixelify-sans-cyrillic-600-normal-B3EuUOZG.woff +0 -0
- package/dist/assets/pixelify-sans-cyrillic-700-normal-BrZTw8tu.woff +0 -0
- package/dist/assets/pixelify-sans-latin-400-normal-DLrIX9QE.woff2 +0 -0
- package/dist/assets/pixelify-sans-latin-400-normal-JapMNN6Z.woff +0 -0
- package/dist/assets/pixelify-sans-latin-600-normal-BNlgYbDL.woff +0 -0
- package/dist/assets/pixelify-sans-latin-600-normal-Dd_Dy7u5.woff2 +0 -0
- package/dist/assets/pixelify-sans-latin-700-normal-BctWAS0u.woff +0 -0
- package/dist/assets/pixelify-sans-latin-700-normal-D3Xxx3QE.woff2 +0 -0
- package/dist/assets/pixelify-sans-latin-ext-400-normal-BkfTUL4s.woff +0 -0
- package/dist/assets/pixelify-sans-latin-ext-400-normal-DdH6zlBQ.woff2 +0 -0
- package/dist/assets/pixelify-sans-latin-ext-600-normal-CDaIumwU.woff +0 -0
- package/dist/assets/pixelify-sans-latin-ext-600-normal-Dha6RiOG.woff2 +0 -0
- package/dist/assets/pixelify-sans-latin-ext-700-normal-BadXkHzZ.woff +0 -0
- package/dist/assets/pixelify-sans-latin-ext-700-normal-ClyJstAh.woff2 +0 -0
- package/dist/assets/vt323-latin-400-normal-Dqn9ZSPj.woff +0 -0
- package/dist/assets/vt323-latin-400-normal-wDgJuOC9.woff2 +0 -0
- package/dist/assets/vt323-latin-ext-400-normal-DqOq58ri.woff +0 -0
- package/dist/assets/vt323-latin-ext-400-normal-iUdYUaX4.woff2 +0 -0
- package/dist/assets/vt323-vietnamese-400-normal-D0VnvwZ9.woff2 +0 -0
- package/dist/assets/vt323-vietnamese-400-normal-D_5Q7Vcv.woff +0 -0
- package/dist/assets/webworkerAll-CkYM11AB.js +83 -0
- package/dist/index.html +14 -0
- package/dist-server/main.mjs +4068 -0
- package/docs/images/agent-drawer.png +0 -0
- package/docs/images/office.png +0 -0
- package/package.json +89 -4
- package/scripts/fix-node-pty.mjs +17 -0
|
@@ -0,0 +1,4068 @@
|
|
|
1
|
+
// server/main.ts
|
|
2
|
+
import { existsSync as existsSync13 } from "node:fs";
|
|
3
|
+
import { dirname as dirname3, join as join13, resolve as resolve4 } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
// src/data/types.ts
|
|
7
|
+
var SERVICE_KEYS = ["anthropic", "openai", "github", "google", "groq", "mistral", "openrouter", "xai", "nebius", "copilot", "cursor", "devin", "droid"];
|
|
8
|
+
var HOST_TERMINAL = "shell:@host";
|
|
9
|
+
|
|
10
|
+
// src/data/names.ts
|
|
11
|
+
var LEAD_NAME = "Michael";
|
|
12
|
+
var WORKER_NAMES = ["Dwight", "Jim", "Pam", "Angela", "Kevin", "Oscar", "Stanley", "Phyllis", "Andy", "Creed", "Meredith"];
|
|
13
|
+
var OFFICE_NAME_MAX = 19;
|
|
14
|
+
|
|
15
|
+
// src/data/stack.ts
|
|
16
|
+
var STACK_CATEGORIES = [
|
|
17
|
+
{ id: "auth", label: "Auth", hint: "Sign-in and users" },
|
|
18
|
+
{ id: "payments", label: "Payments", hint: "Billing and subscriptions" },
|
|
19
|
+
{ id: "database", label: "Database", hint: "Data and branches" },
|
|
20
|
+
{ id: "analytics", label: "Analytics", hint: "Product analytics" },
|
|
21
|
+
{ id: "security", label: "Security", hint: "Bots, rate limits, prompt injection" }
|
|
22
|
+
];
|
|
23
|
+
var fw = (id, name, docs, skills = [], verify = false) => ({
|
|
24
|
+
id,
|
|
25
|
+
name,
|
|
26
|
+
category: "framework",
|
|
27
|
+
docs,
|
|
28
|
+
api: [],
|
|
29
|
+
keys: [],
|
|
30
|
+
skills,
|
|
31
|
+
note: `${name} app`,
|
|
32
|
+
verify
|
|
33
|
+
});
|
|
34
|
+
var REACT = { source: "vercel-labs/agent-skills", skills: ["vercel-react-best-practices", "vercel-composition-patterns"] };
|
|
35
|
+
var STACK_CATALOG = [
|
|
36
|
+
// ---------------------------------------------------------------- frameworks
|
|
37
|
+
fw("nextjs", "Next.js", ["nextjs.org"], [{ source: "vercel/next.js", skills: ["next-dev-loop"] }, REACT]),
|
|
38
|
+
fw("react-vite", "React (Vite)", ["react.dev", "vite.dev"], [REACT]),
|
|
39
|
+
fw("express", "Express", ["expressjs.com"]),
|
|
40
|
+
fw("hono", "Hono", ["hono.dev"]),
|
|
41
|
+
fw("nestjs", "NestJS", ["docs.nestjs.com"]),
|
|
42
|
+
fw("react-router", "React Router", ["reactrouter.com"], [REACT]),
|
|
43
|
+
fw("astro", "Astro", ["docs.astro.build"]),
|
|
44
|
+
fw("sveltekit", "SvelteKit", ["svelte.dev"]),
|
|
45
|
+
fw("nuxt", "Nuxt", ["nuxt.com"]),
|
|
46
|
+
// ---------------------------------------------------------------- auth
|
|
47
|
+
{
|
|
48
|
+
id: "clerk",
|
|
49
|
+
name: "Clerk",
|
|
50
|
+
category: "auth",
|
|
51
|
+
docs: ["clerk.com"],
|
|
52
|
+
api: ["api.clerk.com", "*.clerk.accounts.dev"],
|
|
53
|
+
keys: ["NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY", "CLERK_SECRET_KEY"],
|
|
54
|
+
skills: [{ source: "clerk/skills", skills: ["clerk", "clerk-setup", "clerk-nextjs-patterns", "clerk-react-patterns", "clerk-custom-ui", "clerk-backend-api", "clerk-webhooks", "clerk-orgs"] }],
|
|
55
|
+
note: "Auth uses Clerk"
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: "better-auth",
|
|
59
|
+
name: "Better Auth",
|
|
60
|
+
category: "auth",
|
|
61
|
+
docs: ["better-auth.com"],
|
|
62
|
+
api: [],
|
|
63
|
+
keys: ["BETTER_AUTH_SECRET", "BETTER_AUTH_URL"],
|
|
64
|
+
skills: [],
|
|
65
|
+
note: "Auth uses Better Auth (self-hosted)",
|
|
66
|
+
verify: true
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: "auth0",
|
|
70
|
+
name: "Auth0",
|
|
71
|
+
category: "auth",
|
|
72
|
+
docs: ["auth0.com"],
|
|
73
|
+
api: ["*.auth0.com"],
|
|
74
|
+
keys: ["AUTH0_DOMAIN", "AUTH0_CLIENT_ID", "AUTH0_CLIENT_SECRET", "AUTH0_SECRET"],
|
|
75
|
+
skills: [],
|
|
76
|
+
note: "Auth uses Auth0",
|
|
77
|
+
verify: true
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
id: "supabase-auth",
|
|
81
|
+
name: "Supabase Auth",
|
|
82
|
+
category: "auth",
|
|
83
|
+
docs: ["supabase.com"],
|
|
84
|
+
api: ["*.supabase.co"],
|
|
85
|
+
keys: ["NEXT_PUBLIC_SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY"],
|
|
86
|
+
skills: [{ source: "supabase/agent-skills", skills: ["supabase"] }],
|
|
87
|
+
note: "Auth uses Supabase Auth",
|
|
88
|
+
verify: true
|
|
89
|
+
},
|
|
90
|
+
// ---------------------------------------------------------------- payments
|
|
91
|
+
{
|
|
92
|
+
id: "clerk-billing",
|
|
93
|
+
name: "Clerk Billing",
|
|
94
|
+
category: "payments",
|
|
95
|
+
docs: ["clerk.com"],
|
|
96
|
+
api: ["api.clerk.com"],
|
|
97
|
+
keys: ["CLERK_SECRET_KEY"],
|
|
98
|
+
skills: [{ source: "clerk/skills", skills: ["clerk-billing", "clerk-setup"] }],
|
|
99
|
+
note: "Subscriptions use Clerk Billing"
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
id: "stripe",
|
|
103
|
+
name: "Stripe",
|
|
104
|
+
category: "payments",
|
|
105
|
+
docs: ["docs.stripe.com"],
|
|
106
|
+
api: ["api.stripe.com"],
|
|
107
|
+
keys: ["STRIPE_SECRET_KEY", "STRIPE_WEBHOOK_SECRET", "NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY"],
|
|
108
|
+
skills: [{ source: "stripe/ai", skills: ["stripe-best-practices", "stripe-docs"] }],
|
|
109
|
+
mcp: "https://mcp.stripe.com",
|
|
110
|
+
note: "Payments use Stripe (test mode)"
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
id: "polar",
|
|
114
|
+
name: "Polar",
|
|
115
|
+
category: "payments",
|
|
116
|
+
docs: ["docs.polar.sh"],
|
|
117
|
+
api: ["api.polar.sh", "sandbox-api.polar.sh"],
|
|
118
|
+
keys: ["POLAR_ACCESS_TOKEN", "POLAR_WEBHOOK_SECRET"],
|
|
119
|
+
skills: [],
|
|
120
|
+
note: "Payments use Polar (sandbox)",
|
|
121
|
+
verify: true
|
|
122
|
+
},
|
|
123
|
+
// ---------------------------------------------------------------- database
|
|
124
|
+
{
|
|
125
|
+
id: "instacloud",
|
|
126
|
+
name: "InstaCloud",
|
|
127
|
+
category: "database",
|
|
128
|
+
docs: ["instacloud.com", "agents.instacloud.com"],
|
|
129
|
+
api: ["api.instacloud.com"],
|
|
130
|
+
keys: ["DATABASE_URL"],
|
|
131
|
+
skills: [{ source: "InsForge/instacloud-skills", skills: ["insta"] }],
|
|
132
|
+
mcp: "https://mcp.instacloud.com/mcp",
|
|
133
|
+
note: "Database and branch environments use InstaCloud (the insta CLI)"
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
id: "neon",
|
|
137
|
+
name: "Neon",
|
|
138
|
+
category: "database",
|
|
139
|
+
docs: ["neon.com"],
|
|
140
|
+
api: ["console.neon.tech", "*.neon.tech"],
|
|
141
|
+
keys: ["DATABASE_URL"],
|
|
142
|
+
skills: [],
|
|
143
|
+
note: "Database is Neon Postgres",
|
|
144
|
+
verify: true
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
id: "supabase",
|
|
148
|
+
name: "Supabase",
|
|
149
|
+
category: "database",
|
|
150
|
+
docs: ["supabase.com"],
|
|
151
|
+
api: ["*.supabase.co"],
|
|
152
|
+
keys: ["NEXT_PUBLIC_SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY", "SUPABASE_SECRET_KEY", "DATABASE_URL"],
|
|
153
|
+
skills: [{ source: "supabase/agent-skills", skills: ["supabase", "supabase-postgres-best-practices"] }],
|
|
154
|
+
// Scoped to one project and read-only: without project_ref the MCP reaches every project in the account.
|
|
155
|
+
mcp: "https://mcp.supabase.com/mcp",
|
|
156
|
+
mcpParam: { name: "project_ref", hint: "Tip: add ?project_ref=<id> to limit it to one project (Project Settings \u2192 General). Without it, the agent can reach every project in your account." },
|
|
157
|
+
note: "Database is Supabase Postgres",
|
|
158
|
+
verify: true
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
id: "prisma-postgres",
|
|
162
|
+
name: "Prisma Postgres",
|
|
163
|
+
category: "database",
|
|
164
|
+
docs: ["prisma.io"],
|
|
165
|
+
api: ["db.prisma.io"],
|
|
166
|
+
keys: ["DATABASE_URL"],
|
|
167
|
+
skills: [],
|
|
168
|
+
note: "Database is Prisma Postgres with Prisma ORM",
|
|
169
|
+
verify: true
|
|
170
|
+
},
|
|
171
|
+
// ---------------------------------------------------------------- analytics
|
|
172
|
+
{
|
|
173
|
+
id: "posthog",
|
|
174
|
+
name: "PostHog",
|
|
175
|
+
category: "analytics",
|
|
176
|
+
docs: ["posthog.com"],
|
|
177
|
+
api: ["us.i.posthog.com", "eu.i.posthog.com", "us-assets.i.posthog.com"],
|
|
178
|
+
keys: ["NEXT_PUBLIC_POSTHOG_KEY", "NEXT_PUBLIC_POSTHOG_HOST"],
|
|
179
|
+
skills: [{ source: "PostHog/skills", skills: ["instrument-integration", "instrument-product-analytics", "instrument-llm-analytics"] }],
|
|
180
|
+
mcp: "https://mcp.posthog.com",
|
|
181
|
+
note: "Analytics use PostHog"
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
id: "plausible",
|
|
185
|
+
name: "Plausible",
|
|
186
|
+
category: "analytics",
|
|
187
|
+
docs: ["plausible.io"],
|
|
188
|
+
api: ["plausible.io"],
|
|
189
|
+
keys: [],
|
|
190
|
+
skills: [],
|
|
191
|
+
note: "Analytics use Plausible",
|
|
192
|
+
verify: true
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
id: "vercel-analytics",
|
|
196
|
+
name: "Vercel Analytics",
|
|
197
|
+
category: "analytics",
|
|
198
|
+
docs: ["vercel.com"],
|
|
199
|
+
api: [],
|
|
200
|
+
keys: [],
|
|
201
|
+
skills: [],
|
|
202
|
+
note: "Analytics use Vercel Web Analytics",
|
|
203
|
+
verify: true
|
|
204
|
+
},
|
|
205
|
+
// ---------------------------------------------------------------- security
|
|
206
|
+
{
|
|
207
|
+
id: "arcjet",
|
|
208
|
+
name: "Arcjet",
|
|
209
|
+
category: "security",
|
|
210
|
+
docs: ["docs.arcjet.com"],
|
|
211
|
+
api: ["decide.arcjet.com"],
|
|
212
|
+
keys: ["ARCJET_KEY"],
|
|
213
|
+
skills: [{ source: "arcjet/skills", skills: ["arcjet"] }],
|
|
214
|
+
note: "Bot protection, rate limiting and prompt-injection checks use Arcjet"
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
id: "upstash-ratelimit",
|
|
218
|
+
name: "Upstash Ratelimit",
|
|
219
|
+
category: "security",
|
|
220
|
+
docs: ["upstash.com"],
|
|
221
|
+
api: ["*.upstash.io"],
|
|
222
|
+
keys: ["UPSTASH_REDIS_REST_URL", "UPSTASH_REDIS_REST_TOKEN"],
|
|
223
|
+
skills: [],
|
|
224
|
+
note: "Rate limiting uses Upstash Ratelimit",
|
|
225
|
+
verify: true
|
|
226
|
+
}
|
|
227
|
+
];
|
|
228
|
+
var stackTool = (id) => STACK_CATALOG.find((t2) => t2.id === id);
|
|
229
|
+
function chosenTools(stack) {
|
|
230
|
+
if (!stack) return [];
|
|
231
|
+
const ids = [...stack.frameworks.map((f) => f.id), ...Object.values(stack.tools).map((c) => c?.id)];
|
|
232
|
+
return [...new Set(ids)].flatMap((id) => id ? [stackTool(id)].filter((t2) => !!t2) : []);
|
|
233
|
+
}
|
|
234
|
+
var choiceName = (c) => c.id === "other" ? c.name || "Other" : stackTool(c.id)?.name ?? c.id;
|
|
235
|
+
function stackRoleMd(stack) {
|
|
236
|
+
if (!stack || !stack.frameworks.length && !Object.values(stack.tools).some(Boolean)) return "";
|
|
237
|
+
const lines = ["## Stack"];
|
|
238
|
+
for (const f of stack.frameworks) lines.push(`- ${choiceName(f)}${f.where ? ` in \`${f.where}\`` : ""}${f.id === "other" && f.docsUrl ? ` (docs: ${f.docsUrl})` : ""}`);
|
|
239
|
+
for (const cat of STACK_CATEGORIES) {
|
|
240
|
+
const c = stack.tools[cat.id];
|
|
241
|
+
if (!c) continue;
|
|
242
|
+
const t2 = stackTool(c.id);
|
|
243
|
+
const docs = c.id === "other" ? c.docsUrl : t2?.docs[0];
|
|
244
|
+
lines.push(`- ${t2 ? t2.note : `${cat.label}: ${choiceName(c)}`}${docs ? ` (docs: ${docs})` : ""}`);
|
|
245
|
+
}
|
|
246
|
+
lines.push(
|
|
247
|
+
"",
|
|
248
|
+
"Use the installed agent skills for these tools before writing integration code, and follow their current docs rather than memory.",
|
|
249
|
+
"Secrets come from environment variables; never hard-code or commit them."
|
|
250
|
+
);
|
|
251
|
+
return lines.join("\n");
|
|
252
|
+
}
|
|
253
|
+
function stackSkills(stack) {
|
|
254
|
+
const bySource = /* @__PURE__ */ new Map();
|
|
255
|
+
for (const t2 of chosenTools(stack)) for (const spec of t2.skills) for (const name of spec.skills) bySource.set(spec.source, (bySource.get(spec.source) ?? /* @__PURE__ */ new Set()).add(name));
|
|
256
|
+
return [...bySource].map(([source, names]) => ({ source, skills: [...names].sort() })).sort((a, b) => a.source.localeCompare(b.source));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// server/access.ts
|
|
260
|
+
var DOMAIN = /^(\*\.)?([a-z0-9-]+\.)+[a-z]{2,}(:\d+)?$/;
|
|
261
|
+
function cleanDomain(raw) {
|
|
262
|
+
let v = raw.trim().toLowerCase();
|
|
263
|
+
try {
|
|
264
|
+
if (/^[a-z]+:\/\//.test(v)) v = new URL(v).host;
|
|
265
|
+
} catch {
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
v = v.replace(/\/.*$/, "");
|
|
269
|
+
return DOMAIN.test(v) ? v : null;
|
|
270
|
+
}
|
|
271
|
+
var seq = 0;
|
|
272
|
+
function requestAccess(hub, agentId, rawDomain, reason) {
|
|
273
|
+
const agent = hub.cfg(agentId);
|
|
274
|
+
if (!agent) throw new Error("Unknown agent.");
|
|
275
|
+
if (agent.mode !== "sandbox") return "You run in Local mode, outside a sandbox: HQ doesn't restrict your network. If a request fails, it isn't a sandbox block.";
|
|
276
|
+
const domain = cleanDomain(rawDomain);
|
|
277
|
+
if (!domain) throw new Error(`"${rawDomain}" isn't a domain. Pass just the host, like docs.example.com.`);
|
|
278
|
+
if (agent.sandbox.allow.includes(domain))
|
|
279
|
+
return `${domain} is already allowed for your sandbox. If it still fails, the problem is something else (a typo, the site is down, or a different domain it redirects to).`;
|
|
280
|
+
const open = hub.snap.accessRequests?.find((r) => r.agentId === agentId && r.domain === domain && r.status === "pending");
|
|
281
|
+
if (open) return `You already asked for ${domain}; the user hasn't answered yet. Continue with other work; HQ will tell you.`;
|
|
282
|
+
const req = { id: `ar${Date.now().toString(36)}${++seq}`, agentId, domain, reason: reason.trim().slice(0, 300) || "No reason given", at: (/* @__PURE__ */ new Date()).toISOString(), status: "pending" };
|
|
283
|
+
hub.holdForAccess(agentId);
|
|
284
|
+
const all = [req, ...hub.snap.accessRequests ?? []];
|
|
285
|
+
hub.snap.accessRequests = [...all.filter((r) => r.status === "pending"), ...all.filter((r) => r.status !== "pending").slice(0, 50)];
|
|
286
|
+
hub.emit({ type: "access.updated", request: { ...req } });
|
|
287
|
+
hub.setStatus(agentId, { state: "needs-input", note: `Asking: access to ${domain}` });
|
|
288
|
+
hub.activity("agent.needs-input", agentId, `${agent.name} needs you: access to ${domain}`);
|
|
289
|
+
return `Asked the user to allow ${domain}. Continue with other work if you can; HQ will message you as soon as they answer. Don't work around the block meanwhile.`;
|
|
290
|
+
}
|
|
291
|
+
var queue = Promise.resolve();
|
|
292
|
+
function resolveAccess(hub, sbx, planner, requestId, decision) {
|
|
293
|
+
const next = queue.then(() => answer(hub, sbx, planner, requestId, decision));
|
|
294
|
+
queue = next.catch(() => {
|
|
295
|
+
});
|
|
296
|
+
return next;
|
|
297
|
+
}
|
|
298
|
+
async function answer(hub, sbx, planner, requestId, decision) {
|
|
299
|
+
const req = hub.snap.accessRequests?.find((r) => r.id === requestId);
|
|
300
|
+
if (!req || req.status !== "pending") return;
|
|
301
|
+
const who = hub.name(req.agentId);
|
|
302
|
+
const settle = (r, status) => {
|
|
303
|
+
r.status = status;
|
|
304
|
+
hub.emit({ type: "access.updated", request: { ...r } });
|
|
305
|
+
hub.accessAnswered(r.agentId);
|
|
306
|
+
};
|
|
307
|
+
if (decision === "deny") {
|
|
308
|
+
settle(req, "denied");
|
|
309
|
+
hub.activity("config", req.agentId, `You denied ${who} access to ${req.domain}`);
|
|
310
|
+
planner.notify(req.agentId, `The user denied access to ${req.domain}. Continue without it. If you can't, say why in your reply${hub.cfg(req.agentId)?.isLead ? "" : " and call set_my_status blocked with the reason"}.`);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const ids = decision === "everyone" ? hub.snap.config.team.filter((a) => a.mode === "sandbox").map((a) => a.id) : [req.agentId];
|
|
314
|
+
const added = [];
|
|
315
|
+
for (const id of ids) {
|
|
316
|
+
const a = hub.cfg(id);
|
|
317
|
+
if (a && !a.sandbox.allow.includes(req.domain)) {
|
|
318
|
+
a.sandbox.allow = [...a.sandbox.allow, req.domain];
|
|
319
|
+
added.push(id);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
hub.configChanged();
|
|
323
|
+
let failure = null;
|
|
324
|
+
const results = await Promise.allSettled(ids.map((id) => sbx.syncRules(id)));
|
|
325
|
+
const thrown = results.find((r) => r.status === "rejected");
|
|
326
|
+
const bad = ids.find((id) => sbx.state.agents[id]?.syncError?.includes(req.domain));
|
|
327
|
+
if (thrown) failure = thrown.reason instanceof Error ? thrown.reason.message : String(thrown.reason);
|
|
328
|
+
else if (bad) failure = `${hub.name(bad)}: ${sbx.state.agents[bad]?.syncError}`;
|
|
329
|
+
if (failure) {
|
|
330
|
+
for (const id of added) {
|
|
331
|
+
const a = hub.cfg(id);
|
|
332
|
+
if (a) a.sandbox.allow = a.sandbox.allow.filter((d) => d !== req.domain);
|
|
333
|
+
}
|
|
334
|
+
hub.configChanged();
|
|
335
|
+
await Promise.all(added.map((id) => sbx.syncRules(id).catch(() => {
|
|
336
|
+
})));
|
|
337
|
+
hub.activity("config", req.agentId, `Couldn't allow ${req.domain} (${failure.slice(0, 160)}). The request is still open: try again or deny it.`);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
settle(req, "allowed");
|
|
341
|
+
hub.activity("config", req.agentId, `You allowed ${req.domain} for ${decision === "everyone" ? "every agent" : who}`);
|
|
342
|
+
planner.notify(req.agentId, `The user allowed ${req.domain} for your sandbox. Try the request again.`);
|
|
343
|
+
if (decision === "everyone") {
|
|
344
|
+
for (const other of hub.snap.accessRequests ?? [])
|
|
345
|
+
if (other.status === "pending" && other.domain === req.domain && other.id !== req.id) {
|
|
346
|
+
settle(other, "allowed");
|
|
347
|
+
planner.notify(other.agentId, `The user allowed ${req.domain} for your sandbox. Try the request again.`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// server/skills.ts
|
|
353
|
+
import { cpSync, existsSync as existsSync2, lstatSync as lstatSync3, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync3, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "node:fs";
|
|
354
|
+
|
|
355
|
+
// server/lib/agentFs.ts
|
|
356
|
+
import { lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
357
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
358
|
+
var lstat = (p) => {
|
|
359
|
+
try {
|
|
360
|
+
return lstatSync(p);
|
|
361
|
+
} catch {
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
function agentPath(root, rel) {
|
|
366
|
+
const abs = resolve(root, rel);
|
|
367
|
+
const inside = relative(resolve(root), abs);
|
|
368
|
+
if (!inside || inside.startsWith("..") || resolve(root, inside) !== abs) throw new Error(`${rel} is outside the agent's folder`);
|
|
369
|
+
let cur = resolve(root);
|
|
370
|
+
for (const part of inside.split(sep).slice(0, -1)) {
|
|
371
|
+
cur = join(cur, part);
|
|
372
|
+
if (lstat(cur)?.isSymbolicLink()) throw new Error(`${relative(root, cur)} in ${root} is a symlink; HQ won't write through it. Remove it and start again.`);
|
|
373
|
+
}
|
|
374
|
+
return abs;
|
|
375
|
+
}
|
|
376
|
+
function agentMkdir(root, rel) {
|
|
377
|
+
const abs = agentPath(root, join(rel, "x"));
|
|
378
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
379
|
+
agentPath(root, join(rel, "x"));
|
|
380
|
+
}
|
|
381
|
+
function agentWrite(root, rel, data, opts) {
|
|
382
|
+
const abs = agentPath(root, rel);
|
|
383
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
384
|
+
agentPath(root, rel);
|
|
385
|
+
if (lstat(abs)?.isSymbolicLink()) rmSync(abs);
|
|
386
|
+
writeFileSync(abs, data, opts);
|
|
387
|
+
}
|
|
388
|
+
function agentRead(root, rel) {
|
|
389
|
+
const abs = agentPath(root, rel);
|
|
390
|
+
const st = lstat(abs);
|
|
391
|
+
if (!st || st.isSymbolicLink() || !st.isFile()) return null;
|
|
392
|
+
return readFileSync(abs, "utf8");
|
|
393
|
+
}
|
|
394
|
+
function agentRemove(root, rel) {
|
|
395
|
+
rmSync(agentPath(root, rel), { recursive: true, force: true });
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// server/skills.ts
|
|
399
|
+
import { createHash } from "node:crypto";
|
|
400
|
+
import { join as join4, resolve as resolve2, sep as sep2 } from "node:path";
|
|
401
|
+
|
|
402
|
+
// server/agentFiles.ts
|
|
403
|
+
import { lstatSync as lstatSync2 } from "node:fs";
|
|
404
|
+
|
|
405
|
+
// server/lib/exec.ts
|
|
406
|
+
import { execFile } from "node:child_process";
|
|
407
|
+
function run(cmd, args, opts = {}) {
|
|
408
|
+
return new Promise((resolve5) => {
|
|
409
|
+
const child = execFile(
|
|
410
|
+
cmd,
|
|
411
|
+
args,
|
|
412
|
+
{ cwd: opts.cwd, timeout: opts.timeoutMs ?? 2e4, maxBuffer: 8 * 1024 * 1024, env: process.env },
|
|
413
|
+
(err, stdout, stderr) => {
|
|
414
|
+
const code = err && typeof err.code === "number" ? Number(err.code) : err ? null : 0;
|
|
415
|
+
resolve5({ ok: !err, code: err ? code ?? 1 : 0, stdout: String(stdout), stderr: String(stderr || (err && !stderr ? err.message : "")) });
|
|
416
|
+
}
|
|
417
|
+
);
|
|
418
|
+
if (opts.input !== void 0) child.stdin?.end(opts.input);
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
async function has(cmd) {
|
|
422
|
+
const r = process.platform === "win32" ? await run("where", [cmd], { timeoutMs: 5e3 }) : await run("sh", ["-c", `command -v ${cmd}`], { timeoutMs: 5e3 });
|
|
423
|
+
return r.ok && r.stdout.trim().length > 0;
|
|
424
|
+
}
|
|
425
|
+
function shq(s) {
|
|
426
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// server/agentFiles.ts
|
|
430
|
+
import { join as join2 } from "node:path";
|
|
431
|
+
var MCP_CONFIG = ".claude/hq-mcp.json";
|
|
432
|
+
var HOOK_SCRIPT = ".jsmhq-hook.mjs";
|
|
433
|
+
var ROLE_FILE = ".jsmhq-role.md";
|
|
434
|
+
var HQ_FILES = [ROLE_FILE, ".claude/settings.local.json", MCP_CONFIG, HOOK_SCRIPT];
|
|
435
|
+
var isHqRole = (text) => text.includes("## Team MCP (server `hq`)") && text.includes("You are managed by Setups Works Office.");
|
|
436
|
+
async function adoptRoleFile(dir2) {
|
|
437
|
+
const text = agentRead(dir2, "ROLE.md");
|
|
438
|
+
if (text === null) return null;
|
|
439
|
+
if (!isHqRole(text)) return text.slice(0, 2e4);
|
|
440
|
+
const tracked = await run("git", ["ls-files", "--error-unmatch", "ROLE.md"], { cwd: dir2 });
|
|
441
|
+
if (!tracked.ok) {
|
|
442
|
+
agentRemove(dir2, "ROLE.md");
|
|
443
|
+
removeFromExclude(dir2, ["ROLE.md"]);
|
|
444
|
+
}
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
var MARK = "# jsmhq-hook";
|
|
448
|
+
var MARK_ARG = "--jsmhq-hook";
|
|
449
|
+
var isOurs = (command) => command.includes(MARK) || command.includes(MARK_ARG);
|
|
450
|
+
function hookScriptFor(agent, dir2) {
|
|
451
|
+
if (agent.mode !== "local" || process.platform !== "win32" && process.env.HQ_NODE_HOOKS !== "1") return null;
|
|
452
|
+
return join2(dir2, HOOK_SCRIPT).replace(/\\/g, "/");
|
|
453
|
+
}
|
|
454
|
+
var HOOK_SCRIPT_SOURCE = `// Written by Setups Works Office (git-excluded). Sends this agent's hook events to HQ.
|
|
455
|
+
// Usage: node .jsmhq-hook.mjs <url> <token> [--print-reply] | --print-role
|
|
456
|
+
import { readFileSync } from "node:fs";
|
|
457
|
+
|
|
458
|
+
const args = process.argv.slice(2).filter((a) => a !== "${MARK_ARG}");
|
|
459
|
+
if (args[0] === "--print-role") {
|
|
460
|
+
try {
|
|
461
|
+
process.stdout.write(readFileSync(new URL("./${ROLE_FILE}", import.meta.url), "utf8"));
|
|
462
|
+
} catch {}
|
|
463
|
+
process.exit(0);
|
|
464
|
+
}
|
|
465
|
+
const [url, token, print] = args;
|
|
466
|
+
let body = "";
|
|
467
|
+
let sent = false;
|
|
468
|
+
const send = async () => {
|
|
469
|
+
if (sent) return;
|
|
470
|
+
sent = true;
|
|
471
|
+
try {
|
|
472
|
+
const res = await fetch(url, {
|
|
473
|
+
method: "POST",
|
|
474
|
+
headers: { Authorization: "Bearer " + token, "Content-Type": "application/json" },
|
|
475
|
+
body: body || "{}",
|
|
476
|
+
signal: AbortSignal.timeout(4000),
|
|
477
|
+
});
|
|
478
|
+
// Stop: HQ's reply ({"decision":"block",...}) hands over queued messages.
|
|
479
|
+
if (print === "--print-reply" && res.ok) process.stdout.write(await res.text());
|
|
480
|
+
} catch {}
|
|
481
|
+
process.exit(0);
|
|
482
|
+
};
|
|
483
|
+
process.stdin.setEncoding("utf8");
|
|
484
|
+
process.stdin.on("data", (d) => (body += d));
|
|
485
|
+
process.stdin.on("end", send);
|
|
486
|
+
setTimeout(send, 2000).unref();
|
|
487
|
+
`;
|
|
488
|
+
function writeHookScript(dir2) {
|
|
489
|
+
agentWrite(dir2, HOOK_SCRIPT, HOOK_SCRIPT_SOURCE);
|
|
490
|
+
}
|
|
491
|
+
var HOOK_EVENTS = ["SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "Notification", "Stop", "SessionEnd"];
|
|
492
|
+
function hqUrl(agent, port) {
|
|
493
|
+
return agent.mode === "sandbox" ? `http://host.docker.internal:${port}` : `http://127.0.0.1:${port}`;
|
|
494
|
+
}
|
|
495
|
+
function roleMd(agent, cfg, extras = {}) {
|
|
496
|
+
const team = cfg.team.map((a) => `- ${a.name} \u2014 ${a.role}${a.isLead ? " (lead)" : ""}${a.id === agent.id ? " \u2190 you" : ""}`).join("\n");
|
|
497
|
+
const rules = agent.isLead ? [
|
|
498
|
+
"You plan the work and assign it to the team. You never write app code.",
|
|
499
|
+
"Keep tasks small: one task = one branch = one PR. Give each task the files it may edit and a clear done-when.",
|
|
500
|
+
"Assign only to agents that are running and free (check list_workers).",
|
|
501
|
+
cfg.workflow.merge.by === "lead" ? "You merge PRs once they pass review (`gh pr merge <number> --squash --delete-branch`), one at a time, in an order that avoids conflicts. HQ tells you when each one passes." : "The user merges PRs; never merge one yourself. Decide the order they should land in, and tell the user if one blocks another.",
|
|
502
|
+
"If the user changes who merges, HQ tells you. Follow the latest message."
|
|
503
|
+
] : [
|
|
504
|
+
"Work only on the task you were given. One task = one branch = one PR.",
|
|
505
|
+
"When HQ tells you to start a task, call get_my_task first, then `git pull origin main` and create a branch.",
|
|
506
|
+
"Edit only the files the task lists. Need another file? Ask the lead with send_message.",
|
|
507
|
+
"Title PRs `[TASK-ID] Short title` and open them with `gh pr create`, then call set_my_status in-review with the PR number.",
|
|
508
|
+
"set_my_status tells the lead automatically. Don't also send_message the same news.",
|
|
509
|
+
"If you are blocked or need a decision, call set_my_status blocked with the reason."
|
|
510
|
+
];
|
|
511
|
+
rules.push(
|
|
512
|
+
"When you need the user to do something outside your sandbox (create an account, database or API key, change a setting in a dashboard, put a value in your env file), call ask_user with a short title and clear steps, then keep working on what you can. Never put secret values in it; name the variable instead. Variables reach you through HQ: ask the user to add NAME to your App variables in HQ (Settings \u2192 Sandbox \u2192 App variables) and restart you; there is no env file inside your sandbox to edit."
|
|
513
|
+
);
|
|
514
|
+
if (agent.mode === "sandbox")
|
|
515
|
+
rules.push(
|
|
516
|
+
"Your sandbox blocks most websites and APIs. If a request you need is blocked (a docs page or an API: usually a 403 saying 'Approval required for <host>'), call request_access with the domain and why, then continue with other work until HQ tells you it was answered. Never work around the block."
|
|
517
|
+
);
|
|
518
|
+
const tools = agent.isLead ? "list_workers, get_status, list_tasks, create_board, create_task, update_task, assign_task, send_message, ask_user, request_access" : "get_my_task, set_my_status, list_workers, get_status, list_tasks, send_message, ask_user, request_access";
|
|
519
|
+
return [
|
|
520
|
+
`# ${agent.name} \u2014 ${agent.role}`,
|
|
521
|
+
"",
|
|
522
|
+
agent.roleMd.trim(),
|
|
523
|
+
"",
|
|
524
|
+
"## Team",
|
|
525
|
+
team,
|
|
526
|
+
"",
|
|
527
|
+
"## Rules",
|
|
528
|
+
...rules.map((r) => `- ${r}`),
|
|
529
|
+
"",
|
|
530
|
+
...stackRoleMd(cfg.stack) ? [stackRoleMd(cfg.stack), ""] : [],
|
|
531
|
+
...extras.hasEnvFile ? [
|
|
532
|
+
"## App variables",
|
|
533
|
+
"Your session already has this project's app variables (from HQ's env file) as environment variables. There is no .env file for them. See the names with `printenv | cut -d= -f1`; never print or commit the values. Code reads them from the environment as usual. Don't create a .env file with them unless the user asks.",
|
|
534
|
+
""
|
|
535
|
+
] : [],
|
|
536
|
+
...extras.ownRole?.trim() ? ["## From ROLE.md in this folder", extras.ownRole.trim(), ""] : [],
|
|
537
|
+
"## Team MCP (server `hq`)",
|
|
538
|
+
`Your tools: ${tools}.`,
|
|
539
|
+
"Messages that start with `[HQ]` come from Setups Works Office: assignments from the lead, or messages from teammates. Act on them.",
|
|
540
|
+
"Teammates work in their own sandboxes and cannot see your files or terminal. Talk to them only with send_message.",
|
|
541
|
+
"",
|
|
542
|
+
`Project: ${cfg.project.name} (${cfg.project.repo}). You are managed by Setups Works Office.`,
|
|
543
|
+
""
|
|
544
|
+
].join("\n");
|
|
545
|
+
}
|
|
546
|
+
function hookCommand(url, token, agentId, event, script = null) {
|
|
547
|
+
if (script) return `node "${script}" ${url}/hook/${agentId}/${event} ${token}${event === "Stop" ? " --print-reply" : ""} ${MARK_ARG}`;
|
|
548
|
+
const out = event === "Stop" ? "2>/dev/null" : ">/dev/null 2>&1";
|
|
549
|
+
return `curl -sf -m 4 -X POST -H 'Authorization: Bearer ${token}' -H 'Content-Type: application/json' --data-binary @- '${url}/hook/${agentId}/${event}' ${out} || true ${MARK}`;
|
|
550
|
+
}
|
|
551
|
+
function writeSettings(dir2, agent, port, token) {
|
|
552
|
+
const script = hookScriptFor(agent, dir2);
|
|
553
|
+
const file = ".claude/settings.local.json";
|
|
554
|
+
let settings = {};
|
|
555
|
+
const existing = agentRead(dir2, file);
|
|
556
|
+
if (existing) {
|
|
557
|
+
try {
|
|
558
|
+
settings = JSON.parse(existing);
|
|
559
|
+
} catch {
|
|
560
|
+
settings = {};
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
const hooks = { ...settings.hooks ?? {} };
|
|
564
|
+
const url = hqUrl(agent, port);
|
|
565
|
+
for (const event of HOOK_EVENTS) {
|
|
566
|
+
const kept = (hooks[event] ?? []).map((e) => ({ ...e, hooks: e.hooks.filter((h) => !isOurs(h.command)) })).filter((e) => e.hooks.length);
|
|
567
|
+
const ours = [{ type: "command", command: hookCommand(url, token, agent.id, event, script), timeout: 5 }];
|
|
568
|
+
if (event === "SessionStart")
|
|
569
|
+
ours.unshift({ type: "command", command: script ? `node "${script}" --print-role ${MARK_ARG}` : `cat "$CLAUDE_PROJECT_DIR/${ROLE_FILE}" 2>/dev/null || true ${MARK}` });
|
|
570
|
+
hooks[event] = [...kept, event === "PreToolUse" || event === "PostToolUse" ? { matcher: "*", hooks: ours } : { hooks: ours }];
|
|
571
|
+
}
|
|
572
|
+
const allow = /* @__PURE__ */ new Set([...settings.permissions?.allow ?? [], "mcp__hq"]);
|
|
573
|
+
agentWrite(dir2, file, JSON.stringify({ ...settings, hooks, permissions: { ...settings.permissions ?? {}, allow: [...allow] } }, null, 2) + "\n");
|
|
574
|
+
}
|
|
575
|
+
function writeMcpConfig(dir2, agent, port, token) {
|
|
576
|
+
const config = {
|
|
577
|
+
mcpServers: {
|
|
578
|
+
hq: { type: "http", url: `${hqUrl(agent, port)}/mcp`, headers: { Authorization: `Bearer ${token}`, "X-HQ-Agent": agent.id } }
|
|
579
|
+
}
|
|
580
|
+
};
|
|
581
|
+
agentWrite(dir2, MCP_CONFIG, JSON.stringify(config, null, 2) + "\n", { mode: 384 });
|
|
582
|
+
}
|
|
583
|
+
function writeClaudeFiles(dir2, agent, cfg, token, extras = {}) {
|
|
584
|
+
agentWrite(dir2, ROLE_FILE, roleMd(agent, cfg, extras));
|
|
585
|
+
writeSettings(dir2, agent, cfg.project.hqPort, token);
|
|
586
|
+
writeMcpConfig(dir2, agent, cfg.project.hqPort, token);
|
|
587
|
+
if (hookScriptFor(agent, dir2)) writeHookScript(dir2);
|
|
588
|
+
excludeFromGit(dir2, HQ_FILES);
|
|
589
|
+
}
|
|
590
|
+
function removeFromExclude(dir2, paths) {
|
|
591
|
+
const file = ".git/info/exclude";
|
|
592
|
+
const current = agentRead(dir2, file);
|
|
593
|
+
if (current === null) return;
|
|
594
|
+
const next = current.split("\n").filter((l) => !paths.includes(l)).join("\n");
|
|
595
|
+
if (next !== current) agentWrite(dir2, file, next);
|
|
596
|
+
}
|
|
597
|
+
function excludeFromGit(dir2, paths) {
|
|
598
|
+
try {
|
|
599
|
+
if (!lstatSync2(join2(dir2, ".git")).isDirectory()) return;
|
|
600
|
+
} catch {
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
const file = ".git/info/exclude";
|
|
604
|
+
const current = agentRead(dir2, file) ?? "";
|
|
605
|
+
const missing = paths.filter((p) => !current.split("\n").includes(p));
|
|
606
|
+
if (missing.length) agentWrite(dir2, file, current + (current.endsWith("\n") || !current ? "" : "\n") + "# Setups Works Office\n" + missing.join("\n") + "\n");
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// server/lib/log.ts
|
|
610
|
+
var dim = (s) => `\x1B[2m${s}\x1B[0m`;
|
|
611
|
+
var log = {
|
|
612
|
+
info: (...a) => console.log(dim("[hq]"), ...a),
|
|
613
|
+
warn: (...a) => console.warn("\x1B[33m[hq]\x1B[0m", ...a),
|
|
614
|
+
error: (...a) => console.error("\x1B[31m[hq]\x1B[0m", ...a)
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
// server/paths.ts
|
|
618
|
+
import { randomBytes } from "node:crypto";
|
|
619
|
+
import { existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
620
|
+
import { homedir } from "node:os";
|
|
621
|
+
import { join as join3 } from "node:path";
|
|
622
|
+
var slugify = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "") || "project";
|
|
623
|
+
function projectHome(projectSlug) {
|
|
624
|
+
const dir2 = join3(homedir(), ".jsmhq", projectSlug);
|
|
625
|
+
mkdirSync2(dir2, { recursive: true });
|
|
626
|
+
return dir2;
|
|
627
|
+
}
|
|
628
|
+
function managedWorkspace(projectSlug, agentId) {
|
|
629
|
+
return join3(projectHome(projectSlug), agentId);
|
|
630
|
+
}
|
|
631
|
+
var sessionName = (projectSlug, agentId) => `hq-${projectSlug}-${agentId}`;
|
|
632
|
+
function agentToken(projectSlug) {
|
|
633
|
+
const file = join3(projectHome(projectSlug), "agent-token");
|
|
634
|
+
if (existsSync(file)) return readFileSync2(file, "utf8").trim();
|
|
635
|
+
const tok = randomBytes(24).toString("hex");
|
|
636
|
+
writeFileSync2(file, tok, { mode: 384 });
|
|
637
|
+
return tok;
|
|
638
|
+
}
|
|
639
|
+
function browserToken(projectSlug, dev) {
|
|
640
|
+
if (!dev) return randomBytes(24).toString("hex");
|
|
641
|
+
const file = join3(projectHome(projectSlug), "dev-browser-token");
|
|
642
|
+
if (existsSync(file)) return readFileSync2(file, "utf8").trim();
|
|
643
|
+
const tok = randomBytes(24).toString("hex");
|
|
644
|
+
writeFileSync2(file, tok, { mode: 384 });
|
|
645
|
+
return tok;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// server/skills.ts
|
|
649
|
+
var AGENT_DIRS = [".claude/skills", ".agents/skills"];
|
|
650
|
+
var cacheDir = (slug2) => join4(projectHome(slug2), "skills-cache");
|
|
651
|
+
var key = (specs) => JSON.stringify(specs);
|
|
652
|
+
var installing = null;
|
|
653
|
+
function ensureSkillCache(slug2, specs) {
|
|
654
|
+
const run_ = async () => {
|
|
655
|
+
const dir2 = cacheDir(slug2);
|
|
656
|
+
const stamp = join4(dir2, "installed.json");
|
|
657
|
+
if (existsSync2(stamp) && readFileSync3(stamp, "utf8") === key(specs)) return;
|
|
658
|
+
rmSync2(dir2, { recursive: true, force: true });
|
|
659
|
+
mkdirSync3(dir2, { recursive: true });
|
|
660
|
+
if (!specs.length) return writeFileSync3(stamp, key(specs));
|
|
661
|
+
await run("git", ["init", "-q"], { cwd: dir2 });
|
|
662
|
+
const failed = [];
|
|
663
|
+
for (const spec of specs) {
|
|
664
|
+
const args = ["-y", "skills", "add", spec.source, "--skill", ...spec.skills, "-a", "claude-code", "codex", "-y", "--copy"];
|
|
665
|
+
const [cmd, cmdArgs] = process.platform === "win32" ? ["cmd.exe", ["/d", "/s", "/c", "npx", ...args]] : ["npx", args];
|
|
666
|
+
const r = await run(cmd, cmdArgs, {
|
|
667
|
+
cwd: dir2,
|
|
668
|
+
timeoutMs: 18e4
|
|
669
|
+
});
|
|
670
|
+
if (!r.ok) failed.push(`${spec.source}: ${(r.stderr || r.stdout).trim().split("\n").pop()}`);
|
|
671
|
+
}
|
|
672
|
+
if (failed.length) throw new Error(`Some skills didn't install. ${failed.join("; ")}`);
|
|
673
|
+
writeFileSync3(stamp, key(specs));
|
|
674
|
+
};
|
|
675
|
+
const next = (installing ?? Promise.resolve()).then(run_);
|
|
676
|
+
const settled = next.then(
|
|
677
|
+
() => {
|
|
678
|
+
},
|
|
679
|
+
() => {
|
|
680
|
+
}
|
|
681
|
+
);
|
|
682
|
+
installing = settled;
|
|
683
|
+
void settled.then(() => {
|
|
684
|
+
if (installing === settled) installing = null;
|
|
685
|
+
});
|
|
686
|
+
return next;
|
|
687
|
+
}
|
|
688
|
+
var SAFE = /^\.(claude|agents)\/skills\/[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
689
|
+
var ownedFile = (slug2, workspace) => join4(projectHome(slug2), "skills-owned", `${createHash("sha1").update(resolve2(workspace)).digest("hex").slice(0, 16)}.json`);
|
|
690
|
+
function readOwned(slug2, workspace) {
|
|
691
|
+
try {
|
|
692
|
+
const list = JSON.parse(readFileSync3(ownedFile(slug2, workspace), "utf8"));
|
|
693
|
+
return Array.isArray(list) ? list.filter((p) => typeof p === "string" && SAFE.test(p)) : [];
|
|
694
|
+
} catch {
|
|
695
|
+
return [];
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
var hasHqSkills = (slug2, workspace) => readOwned(slug2, workspace).length > 0;
|
|
699
|
+
async function syncSkillsInto(slug2, workspace) {
|
|
700
|
+
const before = readOwned(slug2, workspace);
|
|
701
|
+
const tracked = async (rel) => (await run("git", ["ls-files", "--error-unmatch", rel], { cwd: workspace })).ok;
|
|
702
|
+
const inside = (rel) => SAFE.test(rel) && resolve2(workspace, rel).startsWith(resolve2(workspace) + sep2);
|
|
703
|
+
const now3 = [];
|
|
704
|
+
const there = (rel) => {
|
|
705
|
+
try {
|
|
706
|
+
lstatSync3(agentPath(workspace, rel));
|
|
707
|
+
return true;
|
|
708
|
+
} catch {
|
|
709
|
+
return false;
|
|
710
|
+
}
|
|
711
|
+
};
|
|
712
|
+
for (const sub of AGENT_DIRS) {
|
|
713
|
+
const from = join4(cacheDir(slug2), sub);
|
|
714
|
+
if (!existsSync2(from)) continue;
|
|
715
|
+
agentMkdir(workspace, sub);
|
|
716
|
+
for (const name of readdirSync(from)) {
|
|
717
|
+
const rel = `${sub}/${name}`;
|
|
718
|
+
if (!inside(rel)) continue;
|
|
719
|
+
if (await tracked(rel)) continue;
|
|
720
|
+
if (there(rel) && !before.includes(rel)) continue;
|
|
721
|
+
agentRemove(workspace, rel);
|
|
722
|
+
cpSync(join4(from, name), agentPath(workspace, rel), { recursive: true });
|
|
723
|
+
now3.push(rel);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
for (const rel of before) {
|
|
727
|
+
if (now3.includes(rel) || !inside(rel) || await tracked(rel)) continue;
|
|
728
|
+
try {
|
|
729
|
+
agentRemove(workspace, rel);
|
|
730
|
+
} catch {
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
mkdirSync3(join4(projectHome(slug2), "skills-owned"), { recursive: true });
|
|
734
|
+
writeFileSync3(ownedFile(slug2, workspace), JSON.stringify(now3));
|
|
735
|
+
excludeFromGit(workspace, now3);
|
|
736
|
+
return now3;
|
|
737
|
+
}
|
|
738
|
+
async function prepareSkills(slug2, workspace, specs) {
|
|
739
|
+
try {
|
|
740
|
+
await ensureSkillCache(slug2, specs);
|
|
741
|
+
const copied = await syncSkillsInto(slug2, workspace);
|
|
742
|
+
return { count: copied.filter((p) => p.startsWith(".claude/")).length, error: null };
|
|
743
|
+
} catch (err) {
|
|
744
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
745
|
+
log.warn(`skills: ${msg}`);
|
|
746
|
+
return { count: 0, error: msg };
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// src/theme/tokens.ts
|
|
751
|
+
var colors = {
|
|
752
|
+
ink: "#241a2e",
|
|
753
|
+
cream: "#fff6e9",
|
|
754
|
+
cream2: "#f0dfc2",
|
|
755
|
+
cream3: "#e2cba6",
|
|
756
|
+
muted: "#7d6b73",
|
|
757
|
+
navy: "#1d2b53",
|
|
758
|
+
navy2: "#16213f",
|
|
759
|
+
blue: "#29adff",
|
|
760
|
+
red: "#ff004d",
|
|
761
|
+
orange: "#ffa300",
|
|
762
|
+
green: "#00e436",
|
|
763
|
+
lav: "#83769c",
|
|
764
|
+
yellow: "#ffec27",
|
|
765
|
+
pink: "#ff77a8",
|
|
766
|
+
grass: "#4a8f3d",
|
|
767
|
+
white: "#fff1e8"
|
|
768
|
+
};
|
|
769
|
+
var stateStyle = {
|
|
770
|
+
working: { label: "Working", color: colors.blue, dim: "#1d6fb8" },
|
|
771
|
+
"needs-input": { label: "Needs input", color: colors.red, dim: "#7e0f33" },
|
|
772
|
+
"in-review": { label: "In review", color: colors.orange, dim: "#a86a00" },
|
|
773
|
+
"review-passed": { label: "Review passed", color: colors.green, dim: "#00873a" },
|
|
774
|
+
idle: { label: "Idle", color: colors.lav, dim: "#5a5170" }
|
|
775
|
+
};
|
|
776
|
+
var reviewStyle = {
|
|
777
|
+
pending: { label: "Queued", color: colors.lav, dim: "#5a5170" },
|
|
778
|
+
reviewing: { label: "Reviewing", color: colors.orange, dim: "#a86a00" },
|
|
779
|
+
"changes-requested": { label: "Changes", color: colors.red, dim: "#7e0f33" },
|
|
780
|
+
passed: { label: "Passed", color: colors.green, dim: "#00873a" }
|
|
781
|
+
};
|
|
782
|
+
var agentPalette = [
|
|
783
|
+
"#ff77a8",
|
|
784
|
+
// pink
|
|
785
|
+
"#29adff",
|
|
786
|
+
// blue
|
|
787
|
+
"#ffd23a",
|
|
788
|
+
// yellow
|
|
789
|
+
"#00e436",
|
|
790
|
+
// green
|
|
791
|
+
"#ff8a3d",
|
|
792
|
+
// orange
|
|
793
|
+
"#a98bff",
|
|
794
|
+
// violet
|
|
795
|
+
"#3ee0d0",
|
|
796
|
+
// teal
|
|
797
|
+
"#ff5c5c",
|
|
798
|
+
// coral
|
|
799
|
+
"#c2c3c7",
|
|
800
|
+
// silver
|
|
801
|
+
"#b0e04a",
|
|
802
|
+
// lime
|
|
803
|
+
"#f0a6ff",
|
|
804
|
+
// lilac
|
|
805
|
+
"#6fa8ff"
|
|
806
|
+
// periwinkle
|
|
807
|
+
];
|
|
808
|
+
|
|
809
|
+
// server/planExport.ts
|
|
810
|
+
import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync4 } from "node:fs";
|
|
811
|
+
import { join as join5 } from "node:path";
|
|
812
|
+
var PLAN_EXPORT_PATH = "docs/plan.md";
|
|
813
|
+
var BOX = { todo: "[ ]", "in-progress": "[ ]", "in-review": "[ ]", blocked: "[ ]", done: "[x]" };
|
|
814
|
+
var STATUS = { todo: "", "in-progress": "in progress", "in-review": "in review", blocked: "blocked", done: "" };
|
|
815
|
+
function planMarkdown(config, plan) {
|
|
816
|
+
const name = (id) => id ? config.team.find((a) => a.id === id)?.name ?? id : null;
|
|
817
|
+
const lines = [`# ${config.project.name} plan`, "", "_Written by Setups Works Office from .jsmhq/plan.json. Edits here are overwritten; change the plan in HQ._", ""];
|
|
818
|
+
for (const b of [...plan.boards].sort((x, y) => x.order - y.order)) {
|
|
819
|
+
lines.push(`## ${b.title} (${b.status})`, "");
|
|
820
|
+
const tasks = plan.tasks.filter((t2) => t2.boardId === b.id);
|
|
821
|
+
if (!tasks.length) lines.push("_No tasks._");
|
|
822
|
+
for (const t2 of tasks) {
|
|
823
|
+
const meta = [name(t2.ownerId), STATUS[t2.status], t2.prNumber ? `PR #${t2.prNumber}` : "", t2.needs.length ? `needs ${t2.needs.join(", ")}` : ""].filter(Boolean);
|
|
824
|
+
lines.push(`- ${BOX[t2.status]} **${t2.id}** ${t2.title}${meta.length ? ` \u2014 ${meta.join(" \xB7 ")}` : ""}`);
|
|
825
|
+
if (t2.description) lines.push(` - ${t2.description.replace(/\s*\n\s*/g, " ")}`);
|
|
826
|
+
if (t2.doneWhen) lines.push(` - Done when: ${t2.doneWhen.replace(/\s*\n\s*/g, " ")}`);
|
|
827
|
+
if (t2.note) lines.push(` - Note: ${t2.note}`);
|
|
828
|
+
}
|
|
829
|
+
lines.push("");
|
|
830
|
+
}
|
|
831
|
+
return lines.join("\n");
|
|
832
|
+
}
|
|
833
|
+
function writePlanExport(root, config, plan) {
|
|
834
|
+
const file = join5(root, PLAN_EXPORT_PATH);
|
|
835
|
+
mkdirSync4(join5(file, ".."), { recursive: true });
|
|
836
|
+
writeFileSync4(file, planMarkdown(config, plan));
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// server/store.ts
|
|
840
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync5, readFileSync as readFileSync4, renameSync, writeFileSync as writeFileSync5 } from "node:fs";
|
|
841
|
+
import { join as join6 } from "node:path";
|
|
842
|
+
var dir = (root) => join6(root, ".jsmhq");
|
|
843
|
+
function readJson(file) {
|
|
844
|
+
if (!existsSync3(file)) return null;
|
|
845
|
+
return JSON.parse(readFileSync4(file, "utf8"));
|
|
846
|
+
}
|
|
847
|
+
function writeJson(file, value) {
|
|
848
|
+
mkdirSync5(join6(file, ".."), { recursive: true });
|
|
849
|
+
const tmp = `${file}.tmp`;
|
|
850
|
+
writeFileSync5(tmp, JSON.stringify(value, null, 2) + "\n");
|
|
851
|
+
renameSync(tmp, file);
|
|
852
|
+
}
|
|
853
|
+
var defaultSandbox = () => ({
|
|
854
|
+
resources: { cpus: null, memoryGb: null },
|
|
855
|
+
// sbx's own defaults
|
|
856
|
+
allow: [],
|
|
857
|
+
deny: []
|
|
858
|
+
});
|
|
859
|
+
function newAgent(id, name, role, color, isLead) {
|
|
860
|
+
return {
|
|
861
|
+
id,
|
|
862
|
+
name,
|
|
863
|
+
role,
|
|
864
|
+
isLead,
|
|
865
|
+
cli: "claude",
|
|
866
|
+
model: "",
|
|
867
|
+
// the CLI's default; users can pick with /model or "Start with model"
|
|
868
|
+
mode: "sandbox",
|
|
869
|
+
color,
|
|
870
|
+
skills: [],
|
|
871
|
+
roleMd: isLead ? `You are ${name}, the lead. You plan and assign work. You never write app code.` : `You are ${name}, a ${role.toLowerCase()} on the team. You build the tasks assigned to you.`,
|
|
872
|
+
sandbox: defaultSandbox(),
|
|
873
|
+
workspacePath: null,
|
|
874
|
+
mcp: []
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
function defaultConfig(repo, port) {
|
|
878
|
+
return {
|
|
879
|
+
project: { id: slugify(repo.name), name: repo.name, repo: repo.github ?? repo.name, path: repo.root, hqPort: port },
|
|
880
|
+
team: [newAgent(slugify(LEAD_NAME), LEAD_NAME, "Lead", "#ff77a8", true), newAgent(slugify(WORKER_NAMES[0]), WORKER_NAMES[0], "Developer", "#29adff", false)],
|
|
881
|
+
workflow: {
|
|
882
|
+
review: { enabled: true, tool: repo.hasCodeRabbit ? "coderabbit" : "human", command: "" },
|
|
883
|
+
merge: { by: "human" },
|
|
884
|
+
deploy: { enabled: repo.hasInsta, tool: "instacloud", command: "", perAgentEnvironments: repo.hasInsta }
|
|
885
|
+
}
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
function loadConfig(repo, port) {
|
|
889
|
+
const file = join6(dir(repo.root), "config.json");
|
|
890
|
+
const found = readJson(file);
|
|
891
|
+
if (found) {
|
|
892
|
+
for (const a of found.team) {
|
|
893
|
+
a.workspacePath ??= null;
|
|
894
|
+
a.mcp ??= [];
|
|
895
|
+
a.cli ??= "claude";
|
|
896
|
+
a.sandbox.resources ??= { cpus: null, memoryGb: null };
|
|
897
|
+
const old = a.sandbox;
|
|
898
|
+
delete old.policy;
|
|
899
|
+
delete old.proxy;
|
|
900
|
+
delete old.secrets;
|
|
901
|
+
}
|
|
902
|
+
found.project.id = slugify(found.project.id ?? found.project.name);
|
|
903
|
+
found.project.path = repo.root;
|
|
904
|
+
return { config: found, isNew: false };
|
|
905
|
+
}
|
|
906
|
+
return { config: defaultConfig(repo, port), isNew: true };
|
|
907
|
+
}
|
|
908
|
+
function saveConfig(root, cfg) {
|
|
909
|
+
const { path: _path, ...project } = cfg.project;
|
|
910
|
+
writeJson(join6(dir(root), "config.json"), { ...cfg, project: { ...project, path: "" } });
|
|
911
|
+
}
|
|
912
|
+
function loadPlan(root) {
|
|
913
|
+
return readJson(join6(dir(root), "plan.json")) ?? { boards: [{ id: "B1", title: "Backlog", status: "active", order: 0 }], tasks: [] };
|
|
914
|
+
}
|
|
915
|
+
function savePlan(root, plan) {
|
|
916
|
+
writeJson(join6(dir(root), "plan.json"), plan);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// server/hub.ts
|
|
920
|
+
var now = () => (/* @__PURE__ */ new Date()).toISOString();
|
|
921
|
+
var Hub = class _Hub {
|
|
922
|
+
constructor(repo, slug2, config, plan, setup) {
|
|
923
|
+
this.repo = repo;
|
|
924
|
+
this.slug = slug2;
|
|
925
|
+
this.snap = {
|
|
926
|
+
setup,
|
|
927
|
+
sbx: { basePolicy: "unknown", proxy: null, globalKeys: [], agents: {}, checkedAt: now() },
|
|
928
|
+
config,
|
|
929
|
+
agents: config.team.map((a) => _Hub.freshStatus(a.id)),
|
|
930
|
+
boards: plan.boards,
|
|
931
|
+
tasks: plan.tasks,
|
|
932
|
+
pullRequests: [],
|
|
933
|
+
environments: [],
|
|
934
|
+
messages: [],
|
|
935
|
+
activity: []
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
repo;
|
|
939
|
+
slug;
|
|
940
|
+
snap;
|
|
941
|
+
listeners = /* @__PURE__ */ new Set();
|
|
942
|
+
seq = 0;
|
|
943
|
+
static freshStatus(agentId) {
|
|
944
|
+
return {
|
|
945
|
+
agentId,
|
|
946
|
+
state: "idle",
|
|
947
|
+
currentTaskId: null,
|
|
948
|
+
stateSince: now(),
|
|
949
|
+
note: "Not started",
|
|
950
|
+
session: { state: "stopped", detail: null, workspace: null, sandbox: null }
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
subscribe(fn) {
|
|
954
|
+
this.listeners.add(fn);
|
|
955
|
+
return () => this.listeners.delete(fn);
|
|
956
|
+
}
|
|
957
|
+
emit(e) {
|
|
958
|
+
for (const fn of this.listeners) fn(e);
|
|
959
|
+
}
|
|
960
|
+
// ------------------------------------------------------------ lookups
|
|
961
|
+
cfg(id) {
|
|
962
|
+
return this.snap.config.team.find((a) => a.id === id);
|
|
963
|
+
}
|
|
964
|
+
status(id) {
|
|
965
|
+
return this.snap.agents.find((a) => a.agentId === id);
|
|
966
|
+
}
|
|
967
|
+
name(id) {
|
|
968
|
+
if (id === "you") return "You";
|
|
969
|
+
if (id === "hq") return "HQ";
|
|
970
|
+
return id && this.cfg(id)?.name || "Someone";
|
|
971
|
+
}
|
|
972
|
+
// ------------------------------------------------------------ mutations
|
|
973
|
+
/** While an access request is open, the agent stays "needs you"; what hooks report meanwhile is kept for after. */
|
|
974
|
+
held = /* @__PURE__ */ new Map();
|
|
975
|
+
/** Remember what the agent was doing before it asked, to return to it after the answer. */
|
|
976
|
+
holdForAccess(agentId) {
|
|
977
|
+
const s = this.status(agentId);
|
|
978
|
+
if (s && !this.held.has(agentId) && s.state !== "needs-input") this.held.set(agentId, { state: s.state, note: s.note });
|
|
979
|
+
}
|
|
980
|
+
pendingAccess(agentId) {
|
|
981
|
+
return this.snap.accessRequests?.find((r) => r.agentId === agentId && r.status === "pending");
|
|
982
|
+
}
|
|
983
|
+
/** An access request was answered: drop back to what the agent is really doing (unless another is open). */
|
|
984
|
+
accessAnswered(agentId) {
|
|
985
|
+
if (this.pendingAccess(agentId)) return;
|
|
986
|
+
const held = this.held.get(agentId);
|
|
987
|
+
this.held.delete(agentId);
|
|
988
|
+
const s = this.status(agentId);
|
|
989
|
+
if (s?.state === "needs-input" && s.note?.startsWith("Asking: access to"))
|
|
990
|
+
this.setStatus(agentId, held ?? { state: "working", note: "Access answered" });
|
|
991
|
+
}
|
|
992
|
+
setStatus(id, patch) {
|
|
993
|
+
const s = this.status(id);
|
|
994
|
+
if (!s) return;
|
|
995
|
+
const open = this.pendingAccess(id);
|
|
996
|
+
if (open && patch.state !== void 0 && patch.state !== "needs-input") {
|
|
997
|
+
this.held.set(id, { state: patch.state, note: patch.note !== void 0 ? patch.note : this.held.get(id)?.note ?? null });
|
|
998
|
+
patch = { ...patch, state: "needs-input", note: `Asking: access to ${open.domain}` };
|
|
999
|
+
}
|
|
1000
|
+
if (patch.state !== void 0 && patch.state !== s.state) s.stateSince = now();
|
|
1001
|
+
Object.assign(s, patch);
|
|
1002
|
+
this.emit({ type: "agent.updated", agent: structuredClone(s) });
|
|
1003
|
+
}
|
|
1004
|
+
setSession(id, patch) {
|
|
1005
|
+
const s = this.status(id);
|
|
1006
|
+
if (!s) return;
|
|
1007
|
+
s.session = { ...s.session, ...patch };
|
|
1008
|
+
this.emit({ type: "agent.updated", agent: structuredClone(s) });
|
|
1009
|
+
}
|
|
1010
|
+
setSetup(setup) {
|
|
1011
|
+
this.snap.setup = setup;
|
|
1012
|
+
this.emit({ type: "setup.updated", setup });
|
|
1013
|
+
}
|
|
1014
|
+
activity(type, agentId, text) {
|
|
1015
|
+
this.seq += 1;
|
|
1016
|
+
const event = { id: `a${this.seq}`, at: now(), type, agentId, text };
|
|
1017
|
+
this.snap.activity = [event, ...this.snap.activity].slice(0, 50);
|
|
1018
|
+
this.emit({ type: "activity", event });
|
|
1019
|
+
}
|
|
1020
|
+
configChanged() {
|
|
1021
|
+
saveConfig(this.repo.root, this.snap.config);
|
|
1022
|
+
this.emit({ type: "config.updated", config: structuredClone(this.snap.config) });
|
|
1023
|
+
}
|
|
1024
|
+
planChanged() {
|
|
1025
|
+
const plan = { boards: this.snap.boards, tasks: this.snap.tasks };
|
|
1026
|
+
savePlan(this.repo.root, plan);
|
|
1027
|
+
if (this.snap.config.project.exportPlan) writePlanExport(this.repo.root, this.snap.config, plan);
|
|
1028
|
+
}
|
|
1029
|
+
terminal(agentId, data) {
|
|
1030
|
+
this.emit({ type: "terminal.output", agentId, data });
|
|
1031
|
+
}
|
|
1032
|
+
};
|
|
1033
|
+
|
|
1034
|
+
// server/github.ts
|
|
1035
|
+
var BASE_FIELDS = "number title url headRefName createdAt mergedAt state mergeable isDraft reviewDecision";
|
|
1036
|
+
var REVIEW_FIELDS = `
|
|
1037
|
+
reviews(last: 10) { nodes { author { login } state submittedAt } }
|
|
1038
|
+
reviewThreads(first: 30) { nodes { isResolved isOutdated comments(first: 1) { nodes { author { login } } } } }
|
|
1039
|
+
commits(last: 1) { nodes { commit { statusCheckRollup { contexts(first: 25) { nodes {
|
|
1040
|
+
__typename
|
|
1041
|
+
... on CheckRun { name status conclusion }
|
|
1042
|
+
... on StatusContext { context state description }
|
|
1043
|
+
} } } } } }`;
|
|
1044
|
+
var OPEN_LIMIT = 20;
|
|
1045
|
+
var QUERY = `query($owner: String!, $name: String!) {
|
|
1046
|
+
rateLimit { cost remaining resetAt }
|
|
1047
|
+
repository(owner: $owner, name: $name) {
|
|
1048
|
+
open: pullRequests(states: OPEN, first: ${OPEN_LIMIT}, orderBy: { field: UPDATED_AT, direction: DESC }) { nodes { ${BASE_FIELDS} ${REVIEW_FIELDS} } }
|
|
1049
|
+
merged: pullRequests(states: MERGED, first: 10, orderBy: { field: UPDATED_AT, direction: DESC }) { nodes { ${BASE_FIELDS} } }
|
|
1050
|
+
closed: pullRequests(states: CLOSED, first: 10, orderBy: { field: UPDATED_AT, direction: DESC }) { nodes { number } }
|
|
1051
|
+
}
|
|
1052
|
+
}`;
|
|
1053
|
+
var RateLimited = class extends Error {
|
|
1054
|
+
constructor(resetAt) {
|
|
1055
|
+
super("GitHub API rate limit reached");
|
|
1056
|
+
this.resetAt = resetAt;
|
|
1057
|
+
}
|
|
1058
|
+
resetAt;
|
|
1059
|
+
};
|
|
1060
|
+
async function graphqlResetAt() {
|
|
1061
|
+
const r = await run("gh", ["api", "rate_limit"], { timeoutMs: 15e3 });
|
|
1062
|
+
try {
|
|
1063
|
+
const reset = JSON.parse(r.stdout).resources?.graphql?.reset;
|
|
1064
|
+
if (reset) return new Date(reset * 1e3);
|
|
1065
|
+
} catch {
|
|
1066
|
+
}
|
|
1067
|
+
return new Date(Date.now() + 15 * 6e4);
|
|
1068
|
+
}
|
|
1069
|
+
async function rateLimited(message) {
|
|
1070
|
+
if (/secondary rate limit/i.test(message)) return new RateLimited(new Date(Date.now() + 2 * 6e4));
|
|
1071
|
+
return new RateLimited(await graphqlResetAt());
|
|
1072
|
+
}
|
|
1073
|
+
var EMPTY_REVIEW = { reviews: { nodes: [] }, reviewThreads: { nodes: [] }, commits: { nodes: [] } };
|
|
1074
|
+
async function fetchPrs(repo) {
|
|
1075
|
+
const [owner, name] = repo.split("/");
|
|
1076
|
+
const r = await run("gh", ["api", "graphql", "-f", `query=${QUERY}`, "-F", `owner=${owner}`, "-F", `name=${name}`], { timeoutMs: 3e4 });
|
|
1077
|
+
if (!r.ok) {
|
|
1078
|
+
const msg = r.stderr.trim().split("\n")[0] || "gh api failed";
|
|
1079
|
+
if (/rate limit/i.test(r.stderr + r.stdout)) throw await rateLimited(r.stderr + r.stdout);
|
|
1080
|
+
throw new Error(msg);
|
|
1081
|
+
}
|
|
1082
|
+
const data = JSON.parse(r.stdout);
|
|
1083
|
+
const limited = data.errors?.find((e) => e.type === "RATE_LIMITED" || /rate limit/i.test(e.message));
|
|
1084
|
+
if (limited) throw await rateLimited(limited.message);
|
|
1085
|
+
if (!data.data?.repository) throw new Error(data.errors?.[0]?.message ?? "Repository not found");
|
|
1086
|
+
return {
|
|
1087
|
+
open: data.data.repository.open.nodes,
|
|
1088
|
+
merged: data.data.repository.merged.nodes.map((pr) => ({ ...pr, ...EMPTY_REVIEW })),
|
|
1089
|
+
closed: (data.data.repository.closed?.nodes ?? []).map((n) => n.number),
|
|
1090
|
+
rate: data.data.rateLimit ?? null
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
var BOTS = {
|
|
1094
|
+
coderabbit: { logins: ["coderabbitai", "coderabbitai[bot]"], check: /coderabbit/i, label: "CodeRabbit" },
|
|
1095
|
+
greptile: { logins: ["greptile-apps", "greptile-apps[bot]"], check: /greptile/i, label: "Greptile" },
|
|
1096
|
+
copilot: { logins: ["copilot-pull-request-reviewer", "copilot-pull-request-reviewer[bot]", "Copilot"], check: /copilot/i, label: "Copilot" }
|
|
1097
|
+
};
|
|
1098
|
+
var contexts = (pr) => pr.commits.nodes[0]?.commit.statusCheckRollup?.contexts.nodes ?? [];
|
|
1099
|
+
var openThreads = (pr, logins) => pr.reviewThreads.nodes.filter((t2) => !t2.isResolved && !t2.isOutdated && (!logins || logins.includes(t2.comments.nodes[0]?.author?.login ?? ""))).length;
|
|
1100
|
+
function reviewOf(pr, tool) {
|
|
1101
|
+
if (tool === "custom") return { review: "pending", openComments: openThreads(pr), note: "Custom review checks aren't run by HQ yet" };
|
|
1102
|
+
if (tool === "human") {
|
|
1103
|
+
const decided = pr.reviewDecision ?? [...pr.reviews.nodes].reverse().find((r) => r.state === "APPROVED" || r.state === "CHANGES_REQUESTED")?.state ?? null;
|
|
1104
|
+
const open2 = openThreads(pr);
|
|
1105
|
+
if (decided === "APPROVED") return { review: "passed", openComments: open2, note: null };
|
|
1106
|
+
if (decided === "CHANGES_REQUESTED") return { review: "changes-requested", openComments: open2, note: null };
|
|
1107
|
+
return { review: "pending", openComments: open2, note: "Waiting for a reviewer" };
|
|
1108
|
+
}
|
|
1109
|
+
const bot = BOTS[tool];
|
|
1110
|
+
const open = openThreads(pr, bot.logins);
|
|
1111
|
+
const ctx = contexts(pr).find((c) => bot.check.test(c.__typename === "CheckRun" ? c.name : c.context));
|
|
1112
|
+
const pending = ctx && (ctx.__typename === "CheckRun" ? ctx.status !== "COMPLETED" : ctx.state === "PENDING" || ctx.state === "EXPECTED");
|
|
1113
|
+
if (pending) return { review: "reviewing", openComments: open, note: null };
|
|
1114
|
+
if (open > 0) return { review: "changes-requested", openComments: open, note: null };
|
|
1115
|
+
const desc = ctx?.__typename === "StatusContext" ? ctx.description ?? "" : "";
|
|
1116
|
+
if (/rate.?limit|skip|paused|disabled/i.test(desc)) return { review: "pending", openComments: 0, note: `${bot.label}: ${desc.toLowerCase()}` };
|
|
1117
|
+
const ok2 = ctx && (ctx.__typename === "CheckRun" ? ctx.conclusion === "SUCCESS" || ctx.conclusion === "NEUTRAL" : ctx.state === "SUCCESS");
|
|
1118
|
+
if (ok2) return { review: "passed", openComments: 0, note: null };
|
|
1119
|
+
if (ctx) return { review: "changes-requested", openComments: 0, note: `${bot.label} check failed` };
|
|
1120
|
+
const botReviewed = pr.reviews.nodes.some((r) => bot.logins.includes(r.author?.login ?? ""));
|
|
1121
|
+
return botReviewed ? { review: "passed", openComments: 0, note: null } : { review: "pending", openComments: 0, note: `Waiting for ${bot.label}` };
|
|
1122
|
+
}
|
|
1123
|
+
var TASK_ID = /([A-Z][A-Z0-9]*-T\d+)/i;
|
|
1124
|
+
function taskIdOf(pr) {
|
|
1125
|
+
const inTitle = pr.title.match(/\[([A-Z][A-Z0-9]*-T\d+)\]/i);
|
|
1126
|
+
if (inTitle) return inTitle[1].toUpperCase();
|
|
1127
|
+
const inBranch = pr.headRefName.match(TASK_ID);
|
|
1128
|
+
return inBranch ? inBranch[1].toUpperCase() : null;
|
|
1129
|
+
}
|
|
1130
|
+
function branchAgent(branch) {
|
|
1131
|
+
const m = branch.match(/^([a-z0-9][a-z0-9-]*)\//i);
|
|
1132
|
+
return m ? m[1].toLowerCase() : null;
|
|
1133
|
+
}
|
|
1134
|
+
function toPullRequest(pr, tool, reviewEnabled, taskId, authorId) {
|
|
1135
|
+
const merged = pr.state === "MERGED";
|
|
1136
|
+
const r = reviewEnabled ? reviewOf(pr, tool) : { review: "passed", openComments: 0, note: null };
|
|
1137
|
+
return {
|
|
1138
|
+
number: pr.number,
|
|
1139
|
+
title: pr.title,
|
|
1140
|
+
taskId,
|
|
1141
|
+
authorId,
|
|
1142
|
+
review: merged && r.review !== "changes-requested" ? "passed" : r.review,
|
|
1143
|
+
openComments: r.openComments,
|
|
1144
|
+
mergeable: pr.mergeable === "MERGEABLE",
|
|
1145
|
+
merged,
|
|
1146
|
+
openedAt: pr.createdAt,
|
|
1147
|
+
url: pr.url,
|
|
1148
|
+
branch: pr.headRefName,
|
|
1149
|
+
closed: pr.state === "CLOSED",
|
|
1150
|
+
conflict: pr.mergeable === "CONFLICTING",
|
|
1151
|
+
reviewNote: pr.isDraft ? "Draft" : r.note
|
|
1152
|
+
};
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// server/githubSync.ts
|
|
1156
|
+
var MERGE_CMD = (n) => `gh pr merge ${n} --squash --delete-branch`;
|
|
1157
|
+
function passedNotice(pr, author, by) {
|
|
1158
|
+
const head = `PR #${pr.number} (${pr.title}) by ${author} passed review.`;
|
|
1159
|
+
return by === "lead" ? `${head} You merge PRs on this project: run \`${MERGE_CMD(pr.number)}\` now. If GitHub refuses (conflicts or failing checks), ask the author to fix it instead. Then assign the next task if anything is waiting.` : `${head} It's ready for the user to merge. Don't merge it yourself.`;
|
|
1160
|
+
}
|
|
1161
|
+
function mergeByNotice(by, waiting) {
|
|
1162
|
+
if (by === "human") return "The user changed the workflow: the user merges PRs now. Don't merge PRs yourself.";
|
|
1163
|
+
const list = waiting.length ? ` These passed review and are waiting: ${waiting.map((n) => `#${n}`).join(", ")}. Merge each with \`${MERGE_CMD("<number>")}\`.` : "";
|
|
1164
|
+
return `The user changed the workflow: you now merge PRs once they pass review.${list}`;
|
|
1165
|
+
}
|
|
1166
|
+
var MIN_POLL_MS = 2e4;
|
|
1167
|
+
var MAX_POLL_MS = 5 * 6e4;
|
|
1168
|
+
var HQ_BUDGET = 1200;
|
|
1169
|
+
var LOW_REMAINING = 800;
|
|
1170
|
+
var label = { coderabbit: "CodeRabbit", greptile: "Greptile", copilot: "Copilot", human: "The reviewer", custom: "The review check" };
|
|
1171
|
+
var GitHubSync = class {
|
|
1172
|
+
constructor(hub, planner, repo) {
|
|
1173
|
+
this.hub = hub;
|
|
1174
|
+
this.planner = planner;
|
|
1175
|
+
this.repo = repo;
|
|
1176
|
+
}
|
|
1177
|
+
hub;
|
|
1178
|
+
planner;
|
|
1179
|
+
repo;
|
|
1180
|
+
prev = /* @__PURE__ */ new Map();
|
|
1181
|
+
first = true;
|
|
1182
|
+
timer = null;
|
|
1183
|
+
nudgeTimer = null;
|
|
1184
|
+
busy = false;
|
|
1185
|
+
lastError = null;
|
|
1186
|
+
lastFetch = 0;
|
|
1187
|
+
interval = MIN_POLL_MS;
|
|
1188
|
+
pausedUntil = 0;
|
|
1189
|
+
start() {
|
|
1190
|
+
if (!this.repo) {
|
|
1191
|
+
log.warn("No GitHub remote: PRs and reviews are off.");
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
void this.tick();
|
|
1195
|
+
let reviewCfg = JSON.stringify(this.hub.snap.config.workflow.review);
|
|
1196
|
+
this.hub.subscribe((e) => {
|
|
1197
|
+
if (e.type === "config.updated" && JSON.stringify(e.config.workflow.review) !== reviewCfg) {
|
|
1198
|
+
reviewCfg = JSON.stringify(e.config.workflow.review);
|
|
1199
|
+
this.first = true;
|
|
1200
|
+
this.nudge(500);
|
|
1201
|
+
}
|
|
1202
|
+
if (e.type === "activity" && e.event.type === "agent.answered") this.nudge();
|
|
1203
|
+
if (e.type === "task.updated" && e.task.status === "in-review") this.nudge(1500);
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
1206
|
+
stop() {
|
|
1207
|
+
if (this.timer) clearTimeout(this.timer);
|
|
1208
|
+
if (this.nudgeTimer) clearTimeout(this.nudgeTimer);
|
|
1209
|
+
}
|
|
1210
|
+
/** One poll, then schedule the next at the current interval. */
|
|
1211
|
+
async tick() {
|
|
1212
|
+
await this.refresh();
|
|
1213
|
+
if (this.timer) clearTimeout(this.timer);
|
|
1214
|
+
this.timer = setTimeout(() => void this.tick(), Math.max(this.interval, this.pausedUntil - Date.now()));
|
|
1215
|
+
}
|
|
1216
|
+
/** Refresh soon (debounced): an agent just opened a PR or finished a turn. Still respects the minimum gap and any pause. */
|
|
1217
|
+
nudge(delayMs = 3e3) {
|
|
1218
|
+
if (!this.repo) return;
|
|
1219
|
+
if (this.nudgeTimer) clearTimeout(this.nudgeTimer);
|
|
1220
|
+
const wait = Math.max(delayMs, this.lastFetch + MIN_POLL_MS - Date.now(), this.pausedUntil - Date.now());
|
|
1221
|
+
this.nudgeTimer = setTimeout(() => void this.refresh(), wait);
|
|
1222
|
+
}
|
|
1223
|
+
/** Poll often enough to stay within HQ's share of the budget; slow down when it runs low. */
|
|
1224
|
+
pace(rate) {
|
|
1225
|
+
if (!rate) return;
|
|
1226
|
+
const forBudget = Math.ceil(Math.max(1, rate.cost) * 36e5 / HQ_BUDGET);
|
|
1227
|
+
const low = rate.remaining < LOW_REMAINING ? 12e4 : 0;
|
|
1228
|
+
this.interval = Math.min(MAX_POLL_MS, Math.max(MIN_POLL_MS, forBudget, low));
|
|
1229
|
+
}
|
|
1230
|
+
async refresh() {
|
|
1231
|
+
if (!this.repo || this.busy || Date.now() < this.pausedUntil) return;
|
|
1232
|
+
if (Date.now() - this.lastFetch < MIN_POLL_MS - 1e3) return;
|
|
1233
|
+
if (!this.hub.snap.setup.checks.find((c) => c.id === "gh" && c.status === "ok")) return;
|
|
1234
|
+
this.busy = true;
|
|
1235
|
+
this.lastFetch = Date.now();
|
|
1236
|
+
try {
|
|
1237
|
+
const { open, merged, closed, rate } = await fetchPrs(this.repo);
|
|
1238
|
+
if (this.lastError) log.info("GitHub reachable again");
|
|
1239
|
+
this.lastError = null;
|
|
1240
|
+
this.pace(rate);
|
|
1241
|
+
const wf = this.hub.snap.config.workflow;
|
|
1242
|
+
const team = new Set(this.hub.snap.config.team.map((a) => a.id));
|
|
1243
|
+
const next = [...open, ...merged].map((gh) => {
|
|
1244
|
+
const taskId = taskIdOf(gh);
|
|
1245
|
+
const task = taskId ? this.planner.task(taskId) : void 0;
|
|
1246
|
+
const fromBranch = branchAgent(gh.headRefName);
|
|
1247
|
+
const authorId = task?.ownerId ?? (fromBranch && team.has(fromBranch) ? fromBranch : "");
|
|
1248
|
+
return toPullRequest(gh, wf.review.tool, wf.review.enabled, task?.id ?? taskId ?? "", authorId);
|
|
1249
|
+
});
|
|
1250
|
+
const seen = new Set(next.map((p) => p.number));
|
|
1251
|
+
const complete = open.length < OPEN_LIMIT;
|
|
1252
|
+
const recentlyClosed = new Set(closed);
|
|
1253
|
+
for (const old of this.prev.values())
|
|
1254
|
+
if (!seen.has(old.number) && !old.merged && !old.closed) next.push(complete || recentlyClosed.has(old.number) ? { ...old, closed: true } : old);
|
|
1255
|
+
for (const pr of next) {
|
|
1256
|
+
const old = this.prev.get(pr.number);
|
|
1257
|
+
if (JSON.stringify(old) !== JSON.stringify(pr)) this.hub.emit({ type: "pr.updated", pr: { ...pr } });
|
|
1258
|
+
if (!this.first) this.react(old, pr);
|
|
1259
|
+
this.syncTask(pr);
|
|
1260
|
+
}
|
|
1261
|
+
this.hub.snap.pullRequests = next;
|
|
1262
|
+
this.prev = new Map(next.map((p) => [p.number, p]));
|
|
1263
|
+
this.first = false;
|
|
1264
|
+
} catch (err) {
|
|
1265
|
+
if (err instanceof RateLimited) {
|
|
1266
|
+
this.pausedUntil = err.resetAt.getTime() + 5e3;
|
|
1267
|
+
const at = err.resetAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
1268
|
+
const msg2 = `GitHub API rate limit reached (shared with your agents' gh commands). PR sync paused until ${at}.`;
|
|
1269
|
+
if (msg2 !== this.lastError) {
|
|
1270
|
+
log.warn(msg2);
|
|
1271
|
+
this.hub.activity("config", null, msg2);
|
|
1272
|
+
}
|
|
1273
|
+
this.lastError = msg2;
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1277
|
+
if (msg !== this.lastError) log.warn(`GitHub: ${msg}`);
|
|
1278
|
+
this.lastError = msg;
|
|
1279
|
+
} finally {
|
|
1280
|
+
this.busy = false;
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
/** Keep the task board in line with its PR. */
|
|
1284
|
+
syncTask(pr) {
|
|
1285
|
+
const task = pr.taskId ? this.planner.task(pr.taskId) : void 0;
|
|
1286
|
+
if (!task || pr.closed) return;
|
|
1287
|
+
if (pr.merged && task.status !== "done") this.planner.updateTask(task.id, { status: "done", prNumber: pr.number });
|
|
1288
|
+
else if (!pr.merged && (task.status === "todo" || task.status === "in-progress")) this.planner.updateTask(task.id, { status: "in-review", prNumber: pr.number });
|
|
1289
|
+
else if (!pr.merged && task.prNumber !== pr.number) this.planner.updateTask(task.id, { prNumber: pr.number });
|
|
1290
|
+
}
|
|
1291
|
+
react(old, pr) {
|
|
1292
|
+
const hub = this.hub;
|
|
1293
|
+
const who = pr.authorId || null;
|
|
1294
|
+
const tool = label[hub.snap.config.workflow.review.tool];
|
|
1295
|
+
const lead = hub.snap.config.team.find((a) => a.isLead);
|
|
1296
|
+
const ref = `#${pr.number} ${pr.title}`;
|
|
1297
|
+
if (!old && !pr.merged && !pr.closed) hub.activity("pr.opened", who, `${hub.name(who)} opened PR ${ref}`);
|
|
1298
|
+
if (pr.closed && !old?.closed) return hub.activity("config", who, `PR #${pr.number} was closed without merging`);
|
|
1299
|
+
if (pr.merged && !old?.merged) {
|
|
1300
|
+
hub.activity("pr.merged", null, `PR ${ref} was merged`);
|
|
1301
|
+
if (lead) this.planner.notify(lead.id, `PR #${pr.number} (${pr.title}) was merged${pr.taskId ? `; ${pr.taskId} is done` : ""}. Assign the next task if anything is waiting.`);
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
if (pr.merged) return;
|
|
1305
|
+
if (pr.review === "reviewing" && old?.review !== "reviewing") hub.activity("review.started", null, `${tool} is reviewing #${pr.number}`);
|
|
1306
|
+
const newComments = pr.review === "changes-requested" && (old?.review !== "changes-requested" || pr.openComments > (old?.openComments ?? 0));
|
|
1307
|
+
if (newComments) {
|
|
1308
|
+
const n = pr.openComments;
|
|
1309
|
+
hub.activity("review.changes", who, `${tool} left ${n || "new"} comment${n === 1 ? "" : "s"} on #${pr.number}`);
|
|
1310
|
+
if (who)
|
|
1311
|
+
this.planner.notify(
|
|
1312
|
+
who,
|
|
1313
|
+
`${tool} reviewed your PR #${pr.number} (${pr.title}) and left ${n || "new"} open comment${n === 1 ? "" : "s"}. Read them with \`gh pr view ${pr.number} --comments\` (or the GitHub API), fix them on the same branch, push, and resolve each thread you fixed.`
|
|
1314
|
+
);
|
|
1315
|
+
}
|
|
1316
|
+
if (pr.review === "passed" && old?.review !== "passed") {
|
|
1317
|
+
hub.activity("review.passed", who, `${tool} approved #${pr.number}`);
|
|
1318
|
+
if (lead) this.planner.notify(lead.id, passedNotice(pr, hub.name(who), hub.snap.config.workflow.merge.by));
|
|
1319
|
+
}
|
|
1320
|
+
if (pr.conflict && !old?.conflict && who)
|
|
1321
|
+
this.planner.notify(who, `Your PR #${pr.number} has merge conflicts with main. Pull main, rebase or merge it into your branch, fix the conflicts, and push.`);
|
|
1322
|
+
}
|
|
1323
|
+
};
|
|
1324
|
+
|
|
1325
|
+
// server/commands.ts
|
|
1326
|
+
function cleanStack(raw) {
|
|
1327
|
+
const input = raw ?? {};
|
|
1328
|
+
const choice = (c, category) => {
|
|
1329
|
+
const v = c ?? {};
|
|
1330
|
+
const id = str(v.id, 40);
|
|
1331
|
+
const known = id === "other" || stackTool(id)?.category === category;
|
|
1332
|
+
if (!known) return null;
|
|
1333
|
+
const out = { id };
|
|
1334
|
+
if (typeof v.where === "string" && v.where.trim()) out.where = str(v.where, 120).trim();
|
|
1335
|
+
if (id === "other") {
|
|
1336
|
+
if (typeof v.name === "string" && v.name.trim()) out.name = str(v.name, 60).trim();
|
|
1337
|
+
if (typeof v.docsUrl === "string" && v.docsUrl.trim()) out.docsUrl = str(v.docsUrl, 300).trim();
|
|
1338
|
+
}
|
|
1339
|
+
return out;
|
|
1340
|
+
};
|
|
1341
|
+
const frameworks = (Array.isArray(input.frameworks) ? input.frameworks : []).slice(0, 10).map((f) => choice(f, "framework")).filter((f) => !!f);
|
|
1342
|
+
const tools = {};
|
|
1343
|
+
for (const c of STACK_CATEGORIES) {
|
|
1344
|
+
const picked = choice(input.tools?.[c.id], c.id);
|
|
1345
|
+
if (picked) tools[c.id] = picked;
|
|
1346
|
+
}
|
|
1347
|
+
return frameworks.length || Object.keys(tools).length ? { frameworks, tools } : null;
|
|
1348
|
+
}
|
|
1349
|
+
var slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "") || "agent";
|
|
1350
|
+
var str = (v, max = 2e3) => typeof v === "string" ? v.slice(0, max) : "";
|
|
1351
|
+
var num = (v) => typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
1352
|
+
function makeCommands(hub, sessions, planner, inbox, sbx, mcp) {
|
|
1353
|
+
const attempt = async (agentId, what, fn) => {
|
|
1354
|
+
try {
|
|
1355
|
+
await fn();
|
|
1356
|
+
hub.activity("config", agentId, what);
|
|
1357
|
+
} catch (err) {
|
|
1358
|
+
hub.activity("config", agentId, `Couldn't ${what.charAt(0).toLowerCase() + what.slice(1)}: ${err instanceof Error ? err.message : err}`);
|
|
1359
|
+
}
|
|
1360
|
+
};
|
|
1361
|
+
const team = () => hub.snap.config.team;
|
|
1362
|
+
const commands = {
|
|
1363
|
+
sendTerminalInput: (id, data) => sessions.input(str(id, 80), str(data, 1e5)),
|
|
1364
|
+
openShell: (id) => sessions.openShell(str(id, 80)),
|
|
1365
|
+
closeShell: (id) => sessions.closeShell(str(id, 80)),
|
|
1366
|
+
openHostShell: (dir2, fresh) => sessions.openHostShell(str(dir2, 4096), fresh === true),
|
|
1367
|
+
closeHostShell: () => sessions.closeHostShell(),
|
|
1368
|
+
forwardPort: (agentId, port) => sbx.forwardPort(str(agentId, 80), num(port)),
|
|
1369
|
+
unforwardPort: (agentId, port) => sbx.unforwardPort(str(agentId, 80), num(port)),
|
|
1370
|
+
resizeTerminal: (id, cols, rows) => sessions.resize(str(id, 80), num(cols), num(rows)),
|
|
1371
|
+
startAgent: (id) => sessions.start(str(id, 80)),
|
|
1372
|
+
stopAgent: (id) => sessions.stop(str(id, 80)),
|
|
1373
|
+
openWorkspace: (id) => sessions.openWorkspace(str(id, 80)),
|
|
1374
|
+
recreateSandbox: (id) => sessions.recreate(str(id, 80)),
|
|
1375
|
+
recheckSetup: async () => {
|
|
1376
|
+
await sessions.recheckSetup();
|
|
1377
|
+
await sbx.refresh();
|
|
1378
|
+
},
|
|
1379
|
+
runSetupFix: (id) => sessions.runFix(str(id, 40)),
|
|
1380
|
+
// You → agent: same delivery path as the lead's assign_task / send_message.
|
|
1381
|
+
assignTask: (taskId, agentId) => planner.assign(str(taskId, 40), str(agentId, 80), "you"),
|
|
1382
|
+
sendMessage: (toId, text) => planner.message("you", str(toId, 80), str(text, 2e3)),
|
|
1383
|
+
addAgent: (raw) => {
|
|
1384
|
+
const input = raw ?? {};
|
|
1385
|
+
const name = str(input.name, 40).trim() || "Agent";
|
|
1386
|
+
let id = slug(name);
|
|
1387
|
+
for (let i = 2; team().some((a) => a.id === id); i++) id = `${slug(name)}-${i}`;
|
|
1388
|
+
const used = new Set(team().map((a) => a.color));
|
|
1389
|
+
const color = agentPalette.find((c) => !used.has(c)) ?? agentPalette[team().length % agentPalette.length];
|
|
1390
|
+
const agent = newAgent(id, name, str(input.role, 40).trim() || "Developer", color, false);
|
|
1391
|
+
agent.cli = input.cli === "codex" ? "codex" : "claude";
|
|
1392
|
+
agent.model = str(input.model, 60).trim();
|
|
1393
|
+
agent.mode = input.mode === "local" ? "local" : "sandbox";
|
|
1394
|
+
const lead = team().find((a) => a.isLead);
|
|
1395
|
+
if (lead) agent.sandbox = structuredClone(lead.sandbox);
|
|
1396
|
+
team().push(agent);
|
|
1397
|
+
const status = Hub.freshStatus(id);
|
|
1398
|
+
hub.snap.agents.push(status);
|
|
1399
|
+
hub.configChanged();
|
|
1400
|
+
hub.emit({ type: "agent.updated", agent: structuredClone(status) });
|
|
1401
|
+
hub.activity("config", null, `Added ${agent.name} (${agent.role}). Press Start to launch their session.`);
|
|
1402
|
+
if (agent.cli === "codex") void sessions.recheckSetup();
|
|
1403
|
+
},
|
|
1404
|
+
updateAgent: (id, patch) => {
|
|
1405
|
+
const agent = hub.cfg(str(id));
|
|
1406
|
+
if (!agent) return;
|
|
1407
|
+
const p = patch ?? {};
|
|
1408
|
+
const before = `${agent.cli}/${agent.mode}`;
|
|
1409
|
+
for (const k of ["name", "role", "model", "color", "roleMd"]) if (typeof p[k] === "string") agent[k] = str(p[k], 8e3);
|
|
1410
|
+
if (p.mode === "sandbox" || p.mode === "local") agent.mode = p.mode;
|
|
1411
|
+
if ((p.cli === "claude" || p.cli === "codex") && p.cli !== agent.cli) {
|
|
1412
|
+
agent.cli = p.cli;
|
|
1413
|
+
if (typeof p.model !== "string") agent.model = "";
|
|
1414
|
+
}
|
|
1415
|
+
if (Array.isArray(p.skills)) agent.skills = p.skills.map((s) => str(s, 60)).filter(Boolean);
|
|
1416
|
+
if (p.workspacePath !== void 0) agent.workspacePath = p.workspacePath ? str(p.workspacePath, 500).trim() || null : null;
|
|
1417
|
+
hub.configChanged();
|
|
1418
|
+
if (`${agent.cli}/${agent.mode}` !== before) void sessions.recheckSetup();
|
|
1419
|
+
},
|
|
1420
|
+
inspectWorkspace: async (id) => {
|
|
1421
|
+
const info = await sessions.inspectWorkspace(str(id, 80));
|
|
1422
|
+
if (info) hub.emit({ type: "workspace.info", info });
|
|
1423
|
+
},
|
|
1424
|
+
removeAgent: async (id, opts) => {
|
|
1425
|
+
const agent = hub.cfg(str(id));
|
|
1426
|
+
if (!agent || agent.isLead) return;
|
|
1427
|
+
const o = opts ?? {};
|
|
1428
|
+
const removed = await sessions.removeResources(agent.id, { deleteSandbox: o.deleteSandbox !== false, deleteFolder: o.deleteFolder === true });
|
|
1429
|
+
hub.snap.config.team = team().filter((a) => a.id !== agent.id);
|
|
1430
|
+
hub.snap.agents = hub.snap.agents.filter((a) => a.agentId !== agent.id);
|
|
1431
|
+
inbox.forget(agent.id);
|
|
1432
|
+
mcp.forget(agent.id);
|
|
1433
|
+
for (const t2 of hub.snap.tasks)
|
|
1434
|
+
if (t2.ownerId === agent.id && t2.status !== "done") {
|
|
1435
|
+
t2.ownerId = null;
|
|
1436
|
+
if (t2.status === "in-progress") t2.status = "todo";
|
|
1437
|
+
hub.emit({ type: "task.updated", task: { ...t2 } });
|
|
1438
|
+
}
|
|
1439
|
+
hub.planChanged();
|
|
1440
|
+
hub.emit({ type: "agent.removed", agentId: agent.id });
|
|
1441
|
+
hub.configChanged();
|
|
1442
|
+
const what = removed.length ? ` Deleted: ${removed.join(" and ")}.` : "";
|
|
1443
|
+
const kept = removed.includes("folder") ? "" : " Their folder is kept.";
|
|
1444
|
+
hub.activity("config", null, `Removed ${agent.name}.${what}${kept}`);
|
|
1445
|
+
},
|
|
1446
|
+
updateSandbox: (id, patch) => {
|
|
1447
|
+
const agent = hub.cfg(str(id));
|
|
1448
|
+
if (!agent) return;
|
|
1449
|
+
const p = patch ?? {};
|
|
1450
|
+
const clean = (list) => [...new Set(list.map((d) => str(d, 200).trim().toLowerCase()).filter(Boolean))];
|
|
1451
|
+
const rulesChanged = Array.isArray(p.allow) || Array.isArray(p.deny);
|
|
1452
|
+
if (Array.isArray(p.allow)) agent.sandbox.allow = clean(p.allow);
|
|
1453
|
+
if (Array.isArray(p.deny)) agent.sandbox.deny = clean(p.deny);
|
|
1454
|
+
if (p.resources) {
|
|
1455
|
+
const m = hub.snap.setup.machine;
|
|
1456
|
+
const cpus2 = p.resources.cpus === null ? null : Math.round(Math.min(m.cpus, Math.max(1, num(p.resources.cpus))));
|
|
1457
|
+
const maxMem = Math.max(0.5, Math.floor(m.memoryGb * 0.75 * 2) / 2);
|
|
1458
|
+
const memoryGb = p.resources.memoryGb === null ? null : Math.min(maxMem, Math.max(0.5, Math.round(num(p.resources.memoryGb) * 2) / 2));
|
|
1459
|
+
agent.sandbox.resources = { cpus: cpus2, memoryGb };
|
|
1460
|
+
}
|
|
1461
|
+
hub.configChanged();
|
|
1462
|
+
if (rulesChanged) void sbx.syncRules(agent.id);
|
|
1463
|
+
},
|
|
1464
|
+
setSecret: (id, service, value) => {
|
|
1465
|
+
const agentId = id === null ? null : str(id, 80);
|
|
1466
|
+
const svc = str(service, 40);
|
|
1467
|
+
if (!SERVICE_KEYS.includes(svc) || typeof value !== "string" || !value.trim()) return;
|
|
1468
|
+
if (agentId && !hub.cfg(agentId)) return;
|
|
1469
|
+
return attempt(agentId, `Stored the ${svc} key ${agentId ? `for ${hub.name(agentId)}'s sandbox` : "for every sandbox"}`, () => sbx.setKey(agentId, svc, value));
|
|
1470
|
+
},
|
|
1471
|
+
removeSecret: (id, service) => {
|
|
1472
|
+
const agentId = id === null ? null : str(id, 80);
|
|
1473
|
+
const svc = str(service, 40);
|
|
1474
|
+
if (!SERVICE_KEYS.includes(svc)) return;
|
|
1475
|
+
return attempt(agentId, `Removed the ${svc} key ${agentId ? `from ${hub.name(agentId)}'s sandbox` : "from every sandbox"}`, () => sbx.removeKey(agentId, svc));
|
|
1476
|
+
},
|
|
1477
|
+
setProxy: (url) => {
|
|
1478
|
+
const u = typeof url === "string" && url.trim() ? str(url, 300).trim() : null;
|
|
1479
|
+
if (u && !/^https?:\/\/[^\s]+$/i.test(u)) return hub.activity("config", null, "Proxy must be an http(s):// URL");
|
|
1480
|
+
return attempt(null, u ? `Set the sandbox proxy to ${u}` : "Removed the sandbox proxy", () => sbx.setProxy(u));
|
|
1481
|
+
},
|
|
1482
|
+
openEnvFile: async (id) => {
|
|
1483
|
+
const agent = hub.cfg(str(id, 80));
|
|
1484
|
+
if (!agent) return;
|
|
1485
|
+
const file = sbx.ensureEnvFile(agent.id);
|
|
1486
|
+
const [cmd, args] = process.platform === "darwin" ? ["open", ["-t", file]] : process.platform === "win32" ? ["notepad", [file]] : ["xdg-open", [file]];
|
|
1487
|
+
await run(cmd, args, { timeoutMs: 5e3 });
|
|
1488
|
+
await sbx.refresh();
|
|
1489
|
+
},
|
|
1490
|
+
refreshSbx: () => sbx.refresh(),
|
|
1491
|
+
resolveActionItem: (id, decision, note) => {
|
|
1492
|
+
const d = decision === "done" || decision === "dismissed" ? decision : null;
|
|
1493
|
+
if (d) planner.actions?.resolve(str(id, 40), d, typeof note === "string" ? str(note, 1e3) : void 0);
|
|
1494
|
+
},
|
|
1495
|
+
refreshMcp: () => mcp.refresh(),
|
|
1496
|
+
addMcp: (raw) => {
|
|
1497
|
+
const v = raw ?? {};
|
|
1498
|
+
return mcp.add({
|
|
1499
|
+
name: str(v.name, 40),
|
|
1500
|
+
url: str(v.url, 500) || void 0,
|
|
1501
|
+
command: str(v.command, 300) || void 0,
|
|
1502
|
+
args: Array.isArray(v.args) ? v.args.slice(0, 20).map((a) => str(a, 200)) : void 0,
|
|
1503
|
+
apiKey: str(v.apiKey, 4e3) || void 0
|
|
1504
|
+
});
|
|
1505
|
+
},
|
|
1506
|
+
authMcp: (name) => mcp.authorize(str(name, 40)),
|
|
1507
|
+
removeMcp: (name) => mcp.remove(str(name, 40)),
|
|
1508
|
+
setAgentMcp: (agentId, names) => mcp.setAgent(str(agentId, 80), Array.isArray(names) ? names.slice(0, 50).map((n) => str(n, 40)) : []),
|
|
1509
|
+
answerApproval: (agentId, approvalId, decision) => {
|
|
1510
|
+
const d = decision === "allow" || decision === "dismiss" ? decision : null;
|
|
1511
|
+
if (d) return sbx.answerApproval(str(agentId, 80), str(approvalId, 120), d);
|
|
1512
|
+
},
|
|
1513
|
+
/** Saves the project's stack (sanitised against the catalog). The agents get it in ROLE.md on their next start. */
|
|
1514
|
+
updateStack: (raw) => {
|
|
1515
|
+
const cfg = hub.snap.config;
|
|
1516
|
+
const stack = cleanStack(raw);
|
|
1517
|
+
if (!stack) {
|
|
1518
|
+
delete cfg.stack;
|
|
1519
|
+
return hub.configChanged();
|
|
1520
|
+
}
|
|
1521
|
+
cfg.stack = stack;
|
|
1522
|
+
hub.configChanged();
|
|
1523
|
+
const specs = stackSkills(cfg.stack);
|
|
1524
|
+
if (specs.length) void ensureSkillCache(hub.slug, specs).catch((e) => hub.activity("config", null, `Couldn't fetch agent skills: ${String(e instanceof Error ? e.message : e).slice(0, 160)}`));
|
|
1525
|
+
},
|
|
1526
|
+
resolveAccess: (id, decision) => {
|
|
1527
|
+
const d = decision === "agent" || decision === "everyone" || decision === "deny" ? decision : null;
|
|
1528
|
+
if (d) return resolveAccess(hub, sbx, planner, str(id, 40), d);
|
|
1529
|
+
},
|
|
1530
|
+
completeWelcome: async (raw) => {
|
|
1531
|
+
if (!hub.snap.welcome) return;
|
|
1532
|
+
const input = raw ?? {};
|
|
1533
|
+
const cli = (c) => c === "codex" ? "codex" : "claude";
|
|
1534
|
+
const used = /* @__PURE__ */ new Set();
|
|
1535
|
+
const make = (name, role, agentCli, isLead) => {
|
|
1536
|
+
const clean = str(name, 40).trim() || (isLead ? "Lead" : "Agent");
|
|
1537
|
+
let id = slug(clean);
|
|
1538
|
+
for (let i = 2; used.has(id); i++) id = `${slug(clean)}-${i}`;
|
|
1539
|
+
used.add(id);
|
|
1540
|
+
const agent = newAgent(id, clean, isLead ? "Lead" : str(role, 40).trim() || "Developer", agentPalette[used.size - 1] ?? agentPalette[0], isLead);
|
|
1541
|
+
agent.cli = agentCli;
|
|
1542
|
+
return agent;
|
|
1543
|
+
};
|
|
1544
|
+
const lead = make(input.lead?.name ?? LEAD_NAME, "Lead", cli(input.lead?.cli), true);
|
|
1545
|
+
const workers = (Array.isArray(input.workers) ? input.workers : []).slice(0, 11).map((w) => make(w?.name ?? "", w?.role ?? "", cli(w?.cli), false));
|
|
1546
|
+
if (!workers.length) return hub.activity("config", null, "Add at least one worker to the team.");
|
|
1547
|
+
const cfg = hub.snap.config;
|
|
1548
|
+
if (typeof input.projectName === "string" && input.projectName.trim()) cfg.project.name = str(input.projectName, 80).trim();
|
|
1549
|
+
cfg.team = [lead, ...workers];
|
|
1550
|
+
const tools = ["coderabbit", "greptile", "copilot", "human", "custom"];
|
|
1551
|
+
if (tools.includes(input.reviewTool)) cfg.workflow.review.tool = input.reviewTool;
|
|
1552
|
+
if (typeof input.reviewEnabled === "boolean") cfg.workflow.review.enabled = input.reviewEnabled;
|
|
1553
|
+
const stack = cleanStack(input.stack);
|
|
1554
|
+
if (stack) {
|
|
1555
|
+
cfg.stack = stack;
|
|
1556
|
+
const specs = stackSkills(stack);
|
|
1557
|
+
if (specs.length) void ensureSkillCache(hub.slug, specs).catch(() => {
|
|
1558
|
+
});
|
|
1559
|
+
}
|
|
1560
|
+
hub.snap.agents = cfg.team.map((a) => Hub.freshStatus(a.id));
|
|
1561
|
+
hub.snap.welcome = false;
|
|
1562
|
+
hub.configChanged();
|
|
1563
|
+
hub.planChanged();
|
|
1564
|
+
await sessions.recheckSetup();
|
|
1565
|
+
await sbx.refresh();
|
|
1566
|
+
hub.emit({ type: "snapshot", snapshot: structuredClone(hub.snap) });
|
|
1567
|
+
hub.activity("config", null, `Welcome! Team created: ${cfg.team.map((a) => a.name).join(", ")}. Press Start team when you're ready.`);
|
|
1568
|
+
},
|
|
1569
|
+
updateWorkflow: (patch) => {
|
|
1570
|
+
const p = patch ?? {};
|
|
1571
|
+
const wf = hub.snap.config.workflow;
|
|
1572
|
+
const mergeBy = wf.merge.by;
|
|
1573
|
+
Object.assign(wf.review, p.review ?? {});
|
|
1574
|
+
if (p.merge?.by === "human" || p.merge?.by === "lead") wf.merge.by = p.merge.by;
|
|
1575
|
+
Object.assign(wf.deploy, p.deploy ?? {});
|
|
1576
|
+
hub.configChanged();
|
|
1577
|
+
const lead = hub.snap.config.team.find((a) => a.isLead);
|
|
1578
|
+
if (lead && wf.merge.by !== mergeBy) {
|
|
1579
|
+
const waiting = hub.snap.pullRequests.filter((pr) => pr.review === "passed" && !pr.merged && !pr.closed).map((pr) => pr.number);
|
|
1580
|
+
planner.notify(lead.id, mergeByNotice(wf.merge.by, waiting));
|
|
1581
|
+
}
|
|
1582
|
+
},
|
|
1583
|
+
updateProject: (patch) => {
|
|
1584
|
+
const p = patch ?? {};
|
|
1585
|
+
const project = hub.snap.config.project;
|
|
1586
|
+
if (typeof p.name === "string" && p.name.trim()) project.name = str(p.name, 80);
|
|
1587
|
+
if (typeof p.repo === "string" && p.repo.trim()) project.repo = str(p.repo, 200);
|
|
1588
|
+
if (typeof p.officeName === "string") {
|
|
1589
|
+
const name = p.officeName.trim().slice(0, OFFICE_NAME_MAX);
|
|
1590
|
+
if (name) project.officeName = name;
|
|
1591
|
+
else delete project.officeName;
|
|
1592
|
+
}
|
|
1593
|
+
const exportOn = typeof p.exportPlan === "boolean" && p.exportPlan && !project.exportPlan;
|
|
1594
|
+
if (typeof p.exportPlan === "boolean") project.exportPlan = p.exportPlan;
|
|
1595
|
+
hub.configChanged();
|
|
1596
|
+
if (exportOn) {
|
|
1597
|
+
hub.planChanged();
|
|
1598
|
+
hub.activity("config", null, "Plan export on: HQ writes docs/plan.md whenever the plan changes.");
|
|
1599
|
+
}
|
|
1600
|
+
},
|
|
1601
|
+
createBoard: (title) => planner.createBoard(str(title, 80)),
|
|
1602
|
+
updateBoard: (id, patch) => {
|
|
1603
|
+
const p = patch ?? {};
|
|
1604
|
+
const status = p.status === "active" || p.status === "planned" || p.status === "done" ? p.status : void 0;
|
|
1605
|
+
planner.updateBoard(str(id, 20), { title: typeof p.title === "string" ? p.title : void 0, status });
|
|
1606
|
+
}
|
|
1607
|
+
};
|
|
1608
|
+
return commands;
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
// server/http.ts
|
|
1612
|
+
import { createReadStream, existsSync as existsSync4, statSync } from "node:fs";
|
|
1613
|
+
import { createServer } from "node:http";
|
|
1614
|
+
import { extname, join as join7, normalize } from "node:path";
|
|
1615
|
+
import { timingSafeEqual } from "node:crypto";
|
|
1616
|
+
import { WebSocketServer } from "ws";
|
|
1617
|
+
|
|
1618
|
+
// server/hooks.ts
|
|
1619
|
+
var clip = (s, n = 80) => s.length > n ? s.slice(0, n - 1) + "\u2026" : s;
|
|
1620
|
+
var turnOpen = /* @__PURE__ */ new Map();
|
|
1621
|
+
function handleHook(hub, inbox, agentId, event, body) {
|
|
1622
|
+
const st = hub.status(agentId);
|
|
1623
|
+
const name = hub.name(agentId);
|
|
1624
|
+
if (!st) return { ok: false };
|
|
1625
|
+
inbox.noteHook(agentId, event);
|
|
1626
|
+
switch (event) {
|
|
1627
|
+
case "SessionStart":
|
|
1628
|
+
turnOpen.set(agentId, false);
|
|
1629
|
+
hub.setStatus(agentId, { state: "idle", note: "Ready" });
|
|
1630
|
+
break;
|
|
1631
|
+
case "UserPromptSubmit":
|
|
1632
|
+
turnOpen.set(agentId, true);
|
|
1633
|
+
hub.setStatus(agentId, { state: "working", note: body.prompt ? clip(body.prompt.replace(/\s+/g, " ")) : null });
|
|
1634
|
+
if (st.state !== "working") hub.activity("task.started", agentId, `${name} is working`);
|
|
1635
|
+
break;
|
|
1636
|
+
case "PreToolUse":
|
|
1637
|
+
if (turnOpen.get(agentId) === false) break;
|
|
1638
|
+
if (st.state !== "working" || st.note?.startsWith("Asking")) hub.setStatus(agentId, { state: "working", note: body.tool_name ? `Using ${body.tool_name}` : null });
|
|
1639
|
+
break;
|
|
1640
|
+
case "PostToolUse":
|
|
1641
|
+
if (turnOpen.get(agentId) === false) break;
|
|
1642
|
+
if (st.state === "needs-input") hub.setStatus(agentId, { state: "working", note: "Continuing after your answer" });
|
|
1643
|
+
break;
|
|
1644
|
+
case "PermissionRequest": {
|
|
1645
|
+
const what = body.tool_name ? `Approve ${body.tool_name}?` : "Approval needed in the terminal";
|
|
1646
|
+
hub.setStatus(agentId, { state: "needs-input", note: `Asking: ${what}` });
|
|
1647
|
+
hub.activity("agent.needs-input", agentId, `${name} needs you: ${what}`);
|
|
1648
|
+
break;
|
|
1649
|
+
}
|
|
1650
|
+
case "Notification": {
|
|
1651
|
+
const msg = body.message ?? "Needs your attention";
|
|
1652
|
+
if (/waiting for your input/i.test(msg) || body.notification_type === "idle_prompt") {
|
|
1653
|
+
hub.setStatus(agentId, { state: "idle", note: "Waiting for you" });
|
|
1654
|
+
break;
|
|
1655
|
+
}
|
|
1656
|
+
hub.setStatus(agentId, { state: "needs-input", note: `Asking: ${clip(msg)}` });
|
|
1657
|
+
hub.activity("agent.needs-input", agentId, `${name} needs you: ${clip(msg, 60)}`);
|
|
1658
|
+
break;
|
|
1659
|
+
}
|
|
1660
|
+
case "Stop": {
|
|
1661
|
+
const queued = inbox.takeForStop(agentId);
|
|
1662
|
+
if (queued) {
|
|
1663
|
+
turnOpen.set(agentId, true);
|
|
1664
|
+
hub.setStatus(agentId, { state: "working", note: "Reading new messages" });
|
|
1665
|
+
return { ok: true, response: { decision: "block", reason: queued } };
|
|
1666
|
+
}
|
|
1667
|
+
turnOpen.set(agentId, false);
|
|
1668
|
+
hub.setStatus(agentId, { state: "idle", note: "Finished \u2014 ready for the next instruction" });
|
|
1669
|
+
hub.activity("agent.answered", agentId, `${name} finished a turn`);
|
|
1670
|
+
break;
|
|
1671
|
+
}
|
|
1672
|
+
case "SessionEnd":
|
|
1673
|
+
turnOpen.set(agentId, false);
|
|
1674
|
+
hub.setStatus(agentId, { state: "idle", note: "Session ended" });
|
|
1675
|
+
break;
|
|
1676
|
+
default:
|
|
1677
|
+
return { ok: false };
|
|
1678
|
+
}
|
|
1679
|
+
return { ok: true };
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
// server/mcp.ts
|
|
1683
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
1684
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
1685
|
+
import { z } from "zod";
|
|
1686
|
+
var ok = (value) => ({ content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }] });
|
|
1687
|
+
var fail = (msg) => ({ content: [{ type: "text", text: msg }], isError: true });
|
|
1688
|
+
function guard(fn) {
|
|
1689
|
+
return async (args) => {
|
|
1690
|
+
try {
|
|
1691
|
+
return fn(args);
|
|
1692
|
+
} catch (err) {
|
|
1693
|
+
return fail(err instanceof Error ? err.message : String(err));
|
|
1694
|
+
}
|
|
1695
|
+
};
|
|
1696
|
+
}
|
|
1697
|
+
function buildServer(hub, planner, me) {
|
|
1698
|
+
const server = new McpServer({ name: "hq", version: "0.1.0" });
|
|
1699
|
+
const isLead = !!hub.cfg(me)?.isLead;
|
|
1700
|
+
const leadOnly = () => {
|
|
1701
|
+
if (!isLead) throw new Error("Only the lead can do this. Ask the lead with send_message.");
|
|
1702
|
+
};
|
|
1703
|
+
const taskView = (id) => {
|
|
1704
|
+
const t2 = planner.task(id);
|
|
1705
|
+
return t2 && { ...t2, owner: t2.ownerId ? hub.name(t2.ownerId) : null };
|
|
1706
|
+
};
|
|
1707
|
+
server.registerTool(
|
|
1708
|
+
"list_workers",
|
|
1709
|
+
{ description: "The team: each agent's id, name, role, whether they are running, and what they are doing now." },
|
|
1710
|
+
guard(
|
|
1711
|
+
() => ok(
|
|
1712
|
+
hub.snap.config.team.map((a) => {
|
|
1713
|
+
const st = hub.status(a.id);
|
|
1714
|
+
return {
|
|
1715
|
+
id: a.id,
|
|
1716
|
+
name: a.name,
|
|
1717
|
+
role: a.role,
|
|
1718
|
+
lead: a.isLead,
|
|
1719
|
+
you: a.id === me,
|
|
1720
|
+
running: st?.session.state === "running",
|
|
1721
|
+
state: st?.state,
|
|
1722
|
+
task: st?.currentTaskId ?? null,
|
|
1723
|
+
free: st?.session.state === "running" && !a.isLead && !planner.currentTask(a.id)
|
|
1724
|
+
};
|
|
1725
|
+
})
|
|
1726
|
+
)
|
|
1727
|
+
)
|
|
1728
|
+
);
|
|
1729
|
+
server.registerTool(
|
|
1730
|
+
"get_status",
|
|
1731
|
+
{ description: "Live status of every agent (state, note, current task) plus the active boards with task counts." },
|
|
1732
|
+
guard(
|
|
1733
|
+
() => ok({
|
|
1734
|
+
agents: hub.snap.agents.map((s) => ({ id: s.agentId, name: hub.name(s.agentId), state: s.state, note: s.note, task: s.currentTaskId, session: s.session.state })),
|
|
1735
|
+
boards: hub.snap.boards.map((b) => {
|
|
1736
|
+
const ts = hub.snap.tasks.filter((t2) => t2.boardId === b.id);
|
|
1737
|
+
const n = (st) => ts.filter((t2) => t2.status === st).length;
|
|
1738
|
+
return { id: b.id, title: b.title, status: b.status, todo: n("todo"), inProgress: n("in-progress"), inReview: n("in-review"), blocked: n("blocked"), done: n("done") };
|
|
1739
|
+
})
|
|
1740
|
+
})
|
|
1741
|
+
)
|
|
1742
|
+
);
|
|
1743
|
+
server.registerTool(
|
|
1744
|
+
"list_tasks",
|
|
1745
|
+
{
|
|
1746
|
+
description: "Tasks, optionally filtered by board, status or owner.",
|
|
1747
|
+
inputSchema: {
|
|
1748
|
+
boardId: z.string().optional().describe("Board id, e.g. P3"),
|
|
1749
|
+
status: z.enum(["todo", "in-progress", "in-review", "done", "blocked"]).optional(),
|
|
1750
|
+
owner: z.string().optional().describe("Agent id or name")
|
|
1751
|
+
}
|
|
1752
|
+
},
|
|
1753
|
+
guard(({ boardId, status, owner }) => {
|
|
1754
|
+
const ownerId = owner ? planner.agentId(owner) : void 0;
|
|
1755
|
+
const list = hub.snap.tasks.filter((t2) => (!boardId || t2.boardId === boardId) && (!status || t2.status === status) && (!owner || t2.ownerId === ownerId));
|
|
1756
|
+
return ok(list.map((t2) => ({ id: t2.id, board: t2.boardId, title: t2.title, status: t2.status, owner: t2.ownerId ? hub.name(t2.ownerId) : null, needs: t2.needs })));
|
|
1757
|
+
})
|
|
1758
|
+
);
|
|
1759
|
+
server.registerTool(
|
|
1760
|
+
"create_board",
|
|
1761
|
+
{ description: "Lead only. Create a board (a phase or an area of work).", inputSchema: { title: z.string(), active: z.boolean().optional().describe("Agents only pick tasks from active boards") } },
|
|
1762
|
+
guard(({ title, active }) => {
|
|
1763
|
+
leadOnly();
|
|
1764
|
+
return ok(planner.createBoard(title, active ? "active" : "planned"));
|
|
1765
|
+
})
|
|
1766
|
+
);
|
|
1767
|
+
server.registerTool(
|
|
1768
|
+
"create_task",
|
|
1769
|
+
{
|
|
1770
|
+
description: "Lead only. Add a task. Keep tasks small: one task = one branch = one PR.",
|
|
1771
|
+
inputSchema: {
|
|
1772
|
+
title: z.string(),
|
|
1773
|
+
boardId: z.string().optional().describe("Defaults to the first active board"),
|
|
1774
|
+
description: z.string().optional().describe("What to build and why"),
|
|
1775
|
+
files: z.array(z.string()).optional().describe("The only files/folders this task may edit"),
|
|
1776
|
+
doneWhen: z.string().optional().describe("Acceptance criteria"),
|
|
1777
|
+
needs: z.array(z.string()).optional().describe("Task ids that must be done first")
|
|
1778
|
+
}
|
|
1779
|
+
},
|
|
1780
|
+
guard((args) => {
|
|
1781
|
+
leadOnly();
|
|
1782
|
+
return ok(planner.createTask(args, me));
|
|
1783
|
+
})
|
|
1784
|
+
);
|
|
1785
|
+
server.registerTool(
|
|
1786
|
+
"update_task",
|
|
1787
|
+
{
|
|
1788
|
+
description: "Lead only. Edit a task's details or status.",
|
|
1789
|
+
inputSchema: {
|
|
1790
|
+
taskId: z.string(),
|
|
1791
|
+
title: z.string().optional(),
|
|
1792
|
+
description: z.string().optional(),
|
|
1793
|
+
files: z.array(z.string()).optional(),
|
|
1794
|
+
doneWhen: z.string().optional(),
|
|
1795
|
+
needs: z.array(z.string()).optional(),
|
|
1796
|
+
status: z.enum(["todo", "in-progress", "in-review", "done", "blocked"]).optional()
|
|
1797
|
+
}
|
|
1798
|
+
},
|
|
1799
|
+
guard(({ taskId, ...patch }) => {
|
|
1800
|
+
leadOnly();
|
|
1801
|
+
return ok(planner.updateTask(taskId, patch));
|
|
1802
|
+
})
|
|
1803
|
+
);
|
|
1804
|
+
server.registerTool(
|
|
1805
|
+
"assign_task",
|
|
1806
|
+
{
|
|
1807
|
+
description: "Lead only. Give a task to an agent. HQ tells them to pull main and start it (right away if idle, otherwise when their current turn ends).",
|
|
1808
|
+
inputSchema: { taskId: z.string(), agent: z.string().describe("Agent id or name") }
|
|
1809
|
+
},
|
|
1810
|
+
guard(({ taskId, agent }) => {
|
|
1811
|
+
leadOnly();
|
|
1812
|
+
const t2 = planner.assign(taskId, agent, me);
|
|
1813
|
+
return ok(`Assigned ${t2.id} to ${hub.name(t2.ownerId)}. They have been told to start.`);
|
|
1814
|
+
})
|
|
1815
|
+
);
|
|
1816
|
+
server.registerTool(
|
|
1817
|
+
"get_my_task",
|
|
1818
|
+
{ description: "Your current task: title, description, the files you may edit, done-when, and what it needs. Marks it in progress." },
|
|
1819
|
+
guard(() => {
|
|
1820
|
+
const t2 = planner.currentTask(me);
|
|
1821
|
+
if (!t2) return ok("You have no task right now. Wait for the lead, or ask with send_message.");
|
|
1822
|
+
if (t2.status === "todo") planner.setStatus(me, "in-progress");
|
|
1823
|
+
const blockedBy = t2.needs.filter((n) => planner.task(n)?.status !== "done");
|
|
1824
|
+
return ok({ ...taskView(t2.id), waitingOn: blockedBy, rules: "Work only on this task, on its own branch. Edit only the listed files. Title the PR `[" + t2.id + "] " + t2.title + "`. When the PR is open, call set_my_status with in-review and the PR number." });
|
|
1825
|
+
})
|
|
1826
|
+
);
|
|
1827
|
+
server.registerTool(
|
|
1828
|
+
"set_my_status",
|
|
1829
|
+
{
|
|
1830
|
+
description: "Update your current task: in-progress, blocked (say why), in-review (PR opened), or done. The lead is told automatically.",
|
|
1831
|
+
inputSchema: {
|
|
1832
|
+
status: z.enum(["in-progress", "blocked", "in-review", "done"]),
|
|
1833
|
+
note: z.string().optional().describe("Why you are blocked, or a short summary"),
|
|
1834
|
+
prNumber: z.number().int().optional()
|
|
1835
|
+
}
|
|
1836
|
+
},
|
|
1837
|
+
guard(({ status, note, prNumber }) => {
|
|
1838
|
+
const t2 = planner.setStatus(me, status, note, prNumber);
|
|
1839
|
+
return ok(`${t2.id} is now ${t2.status}.`);
|
|
1840
|
+
})
|
|
1841
|
+
);
|
|
1842
|
+
server.registerTool(
|
|
1843
|
+
"ask_user",
|
|
1844
|
+
{
|
|
1845
|
+
description: "Ask the user to do something you can't do from your sandbox: create an account, database or API key, change a setting in a dashboard (Vercel, Supabase\u2026), or put a value in your env file. It goes to their Action items list. Give a short title and clear steps. Never include secret values; name the variable instead. HQ messages you when they've done it. For a variable you need, write: add NAME to <your name>'s App variables in HQ (Settings \u2192 Sandbox \u2192 App variables), then restart <your name>. For a site you need, use request_access instead.",
|
|
1846
|
+
inputSchema: {
|
|
1847
|
+
title: z.string().describe("What to do, in a few words"),
|
|
1848
|
+
details: z.string().describe("The exact steps: where to go, what to create, which variable name, where to put it, and whether you need a restart. No secret values."),
|
|
1849
|
+
waiting: z.boolean().optional().describe("True if you can't continue without it")
|
|
1850
|
+
}
|
|
1851
|
+
},
|
|
1852
|
+
guard(({ title, details, waiting }) => {
|
|
1853
|
+
if (!planner.actions) throw new Error("Action items aren't available.");
|
|
1854
|
+
if (!details.trim()) throw new Error("Add the steps in details, so the user knows exactly what to do.");
|
|
1855
|
+
const item = planner.actions.add(me, title, details, !!waiting);
|
|
1856
|
+
return ok(`Added to the user's Action items (${item.id}). ${waiting ? "Wait for HQ's message, or work on something else meanwhile." : "Keep working; HQ will message you when it's done."}`);
|
|
1857
|
+
})
|
|
1858
|
+
);
|
|
1859
|
+
server.registerTool(
|
|
1860
|
+
"request_access",
|
|
1861
|
+
{
|
|
1862
|
+
description: "Your sandbox blocks most websites and APIs. When a request you need is blocked (docs or an API), ask the user to allow that domain. Pass the host (e.g. docs.example.com) and why you need it. The user answers in HQ; you'll get a message.",
|
|
1863
|
+
inputSchema: { domain: z.string(), reason: z.string() }
|
|
1864
|
+
},
|
|
1865
|
+
guard(({ domain, reason }) => ok(requestAccess(hub, me, domain, reason)))
|
|
1866
|
+
);
|
|
1867
|
+
server.registerTool(
|
|
1868
|
+
"send_message",
|
|
1869
|
+
{
|
|
1870
|
+
description: 'Send a message to another agent (id, name, or "lead"). HQ delivers it: right away if they are idle, otherwise when their current turn ends.',
|
|
1871
|
+
inputSchema: { to: z.string(), text: z.string() }
|
|
1872
|
+
},
|
|
1873
|
+
guard(({ to, text }) => {
|
|
1874
|
+
const toId = planner.agentId(to);
|
|
1875
|
+
if (toId === me) throw new Error("That's you.");
|
|
1876
|
+
planner.message(me, to, text);
|
|
1877
|
+
return ok(`Sent to ${hub.name(toId)}.`);
|
|
1878
|
+
})
|
|
1879
|
+
);
|
|
1880
|
+
return server;
|
|
1881
|
+
}
|
|
1882
|
+
async function handleMcp(hub, planner, agentId, req, res, body) {
|
|
1883
|
+
const server = buildServer(hub, planner, agentId);
|
|
1884
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: void 0, enableJsonResponse: true });
|
|
1885
|
+
res.on("close", () => {
|
|
1886
|
+
void transport.close();
|
|
1887
|
+
void server.close();
|
|
1888
|
+
});
|
|
1889
|
+
await server.connect(transport);
|
|
1890
|
+
await transport.handleRequest(req, res, body);
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
// server/http.ts
|
|
1894
|
+
var MIME = {
|
|
1895
|
+
".html": "text/html; charset=utf-8",
|
|
1896
|
+
".js": "text/javascript",
|
|
1897
|
+
".css": "text/css",
|
|
1898
|
+
".svg": "image/svg+xml",
|
|
1899
|
+
".woff2": "font/woff2",
|
|
1900
|
+
".woff": "font/woff",
|
|
1901
|
+
".png": "image/png",
|
|
1902
|
+
".json": "application/json"
|
|
1903
|
+
};
|
|
1904
|
+
function safeEqual(a, b) {
|
|
1905
|
+
const x = Buffer.from(a);
|
|
1906
|
+
const y = Buffer.from(b);
|
|
1907
|
+
return x.length === y.length && timingSafeEqual(x, y);
|
|
1908
|
+
}
|
|
1909
|
+
function readBody(req, limit = 256 * 1024) {
|
|
1910
|
+
return new Promise((resolve5, reject) => {
|
|
1911
|
+
let size = 0;
|
|
1912
|
+
const chunks = [];
|
|
1913
|
+
req.on("data", (c) => {
|
|
1914
|
+
size += c.length;
|
|
1915
|
+
if (size > limit) reject(new Error("too large"));
|
|
1916
|
+
else chunks.push(c);
|
|
1917
|
+
});
|
|
1918
|
+
req.on("end", () => resolve5(Buffer.concat(chunks).toString("utf8")));
|
|
1919
|
+
req.on("error", reject);
|
|
1920
|
+
});
|
|
1921
|
+
}
|
|
1922
|
+
function startHttp(o) {
|
|
1923
|
+
const send = (res, code, body, type = "text/plain") => {
|
|
1924
|
+
res.writeHead(code, { "Content-Type": type });
|
|
1925
|
+
res.end(body);
|
|
1926
|
+
};
|
|
1927
|
+
const server = createServer(async (req, res) => {
|
|
1928
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
1929
|
+
if (req.method === "POST" && url.pathname.startsWith("/hook/")) {
|
|
1930
|
+
const auth = req.headers.authorization ?? "";
|
|
1931
|
+
if (!safeEqual(auth, `Bearer ${o.agentToken}`)) return send(res, 401, "unauthorized");
|
|
1932
|
+
const [, , agentId, event] = url.pathname.split("/");
|
|
1933
|
+
try {
|
|
1934
|
+
const body = await readBody(req);
|
|
1935
|
+
const r = handleHook(o.hub, o.inbox, decodeURIComponent(agentId ?? ""), event ?? "", body ? JSON.parse(body) : {});
|
|
1936
|
+
if (!r.ok) return send(res, 404, "");
|
|
1937
|
+
if (r.response) return send(res, 200, JSON.stringify(r.response), "application/json");
|
|
1938
|
+
res.writeHead(204);
|
|
1939
|
+
return void res.end();
|
|
1940
|
+
} catch {
|
|
1941
|
+
return send(res, 400, "bad request");
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
if (url.pathname === "/mcp") {
|
|
1945
|
+
const auth = req.headers.authorization ?? "";
|
|
1946
|
+
const agentId = String(req.headers["x-hq-agent"] ?? "");
|
|
1947
|
+
if (!safeEqual(auth, `Bearer ${o.agentToken}`) || !o.hub.cfg(agentId)) return send(res, 401, "unauthorized");
|
|
1948
|
+
if (req.method === "GET" || req.method === "DELETE") return send(res, 405, "stateless server");
|
|
1949
|
+
try {
|
|
1950
|
+
const raw = await readBody(req, 1024 * 1024);
|
|
1951
|
+
return await handleMcp(o.hub, o.planner, agentId, req, res, raw ? JSON.parse(raw) : void 0);
|
|
1952
|
+
} catch {
|
|
1953
|
+
return res.headersSent ? void res.end() : send(res, 400, "bad request");
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
if (url.pathname === "/api/health") return send(res, 200, JSON.stringify({ ok: true }), "application/json");
|
|
1957
|
+
if (req.method === "GET" && o.distDir) {
|
|
1958
|
+
const rel = normalize(url.pathname === "/" ? "/index.html" : url.pathname).replace(/^(\.\.[/\\])+/, "");
|
|
1959
|
+
let file = join7(o.distDir, rel);
|
|
1960
|
+
if (!file.startsWith(o.distDir) || !existsSync4(file) || statSync(file).isDirectory()) file = join7(o.distDir, "index.html");
|
|
1961
|
+
res.writeHead(200, { "Content-Type": MIME[extname(file)] ?? "application/octet-stream" });
|
|
1962
|
+
return void createReadStream(file).pipe(res);
|
|
1963
|
+
}
|
|
1964
|
+
send(res, 404, "not found");
|
|
1965
|
+
});
|
|
1966
|
+
const wss = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024 });
|
|
1967
|
+
server.on("upgrade", (req, socket, head) => {
|
|
1968
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
1969
|
+
const origin = req.headers.origin ?? "";
|
|
1970
|
+
const token = url.searchParams.get("token") ?? "";
|
|
1971
|
+
if (url.pathname !== "/ws" || !safeEqual(token, o.browserToken) || origin && !o.allowedOrigins.includes(origin)) {
|
|
1972
|
+
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
|
|
1973
|
+
return socket.destroy();
|
|
1974
|
+
}
|
|
1975
|
+
wss.handleUpgrade(req, socket, head, (ws) => onClient(ws));
|
|
1976
|
+
});
|
|
1977
|
+
function onClient(ws) {
|
|
1978
|
+
const sendEvent = (e) => ws.readyState === ws.OPEN && ws.send(JSON.stringify(e));
|
|
1979
|
+
sendEvent({ type: "snapshot", snapshot: o.hub.snap });
|
|
1980
|
+
const unsub = o.hub.subscribe(sendEvent);
|
|
1981
|
+
ws.on("message", async (raw) => {
|
|
1982
|
+
try {
|
|
1983
|
+
const msg = JSON.parse(String(raw));
|
|
1984
|
+
const fn = msg.cmd ? o.commands[msg.cmd] : void 0;
|
|
1985
|
+
if (!fn) return log.warn("unknown command", msg.cmd);
|
|
1986
|
+
await fn(...Array.isArray(msg.args) ? msg.args : []);
|
|
1987
|
+
} catch (err) {
|
|
1988
|
+
log.warn("command failed:", err instanceof Error ? err.message : err);
|
|
1989
|
+
}
|
|
1990
|
+
});
|
|
1991
|
+
ws.on("close", () => unsub());
|
|
1992
|
+
}
|
|
1993
|
+
return new Promise((resolve5, reject) => {
|
|
1994
|
+
server.once("error", reject);
|
|
1995
|
+
server.listen(o.port, "127.0.0.1", () => resolve5(server));
|
|
1996
|
+
});
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
// server/inbox.ts
|
|
2000
|
+
var QUIET_MS = 1e3;
|
|
2001
|
+
var SUBMIT_CHECK_MS = 3e3;
|
|
2002
|
+
var Inbox = class {
|
|
2003
|
+
constructor(hub, keys) {
|
|
2004
|
+
this.hub = hub;
|
|
2005
|
+
this.keys = keys;
|
|
2006
|
+
this.timer = setInterval(() => {
|
|
2007
|
+
for (const id of this.queues.keys()) void this.tryType(id);
|
|
2008
|
+
}, 2e3);
|
|
2009
|
+
}
|
|
2010
|
+
hub;
|
|
2011
|
+
keys;
|
|
2012
|
+
queues = /* @__PURE__ */ new Map();
|
|
2013
|
+
sessionUp = /* @__PURE__ */ new Set();
|
|
2014
|
+
// SessionStart seen: Claude Code's prompt exists
|
|
2015
|
+
lastHook = /* @__PURE__ */ new Map();
|
|
2016
|
+
lastSubmit = /* @__PURE__ */ new Map();
|
|
2017
|
+
typing = /* @__PURE__ */ new Set();
|
|
2018
|
+
timer;
|
|
2019
|
+
stop() {
|
|
2020
|
+
clearInterval(this.timer);
|
|
2021
|
+
}
|
|
2022
|
+
push(agentId, item) {
|
|
2023
|
+
const q2 = this.queues.get(agentId) ?? [];
|
|
2024
|
+
q2.push(item);
|
|
2025
|
+
this.queues.set(agentId, q2);
|
|
2026
|
+
void this.tryType(agentId);
|
|
2027
|
+
}
|
|
2028
|
+
/** When the agent's last hook arrived (0 if never). */
|
|
2029
|
+
lastHookAt(agentId) {
|
|
2030
|
+
return this.lastHook.get(agentId) ?? 0;
|
|
2031
|
+
}
|
|
2032
|
+
pending(agentId) {
|
|
2033
|
+
return this.queues.get(agentId)?.length ?? 0;
|
|
2034
|
+
}
|
|
2035
|
+
/** Every hook event goes through here (before the state change it causes). */
|
|
2036
|
+
noteHook(agentId, event) {
|
|
2037
|
+
this.lastHook.set(agentId, Date.now());
|
|
2038
|
+
if (event === "SessionStart") this.sessionUp.add(agentId);
|
|
2039
|
+
if (event === "SessionEnd") this.sessionUp.delete(agentId);
|
|
2040
|
+
if (event === "UserPromptSubmit") this.lastSubmit.set(agentId, Date.now());
|
|
2041
|
+
if (event === "Stop" || event === "SessionStart" || event === "Notification") setTimeout(() => void this.tryType(agentId), QUIET_MS + 100);
|
|
2042
|
+
}
|
|
2043
|
+
/** An already-running session was reattached: its prompt exists even though HQ missed SessionStart. */
|
|
2044
|
+
sessionReattached(agentId) {
|
|
2045
|
+
this.sessionUp.add(agentId);
|
|
2046
|
+
}
|
|
2047
|
+
/** The session stopped or restarted: Claude Code's prompt is gone until the next SessionStart. */
|
|
2048
|
+
sessionDown(agentId) {
|
|
2049
|
+
this.sessionUp.delete(agentId);
|
|
2050
|
+
}
|
|
2051
|
+
forget(agentId) {
|
|
2052
|
+
this.queues.delete(agentId);
|
|
2053
|
+
this.sessionUp.delete(agentId);
|
|
2054
|
+
}
|
|
2055
|
+
/** Called from the Stop hook: everything queued, as the reason Claude continues with. */
|
|
2056
|
+
takeForStop(agentId) {
|
|
2057
|
+
const q2 = this.queues.get(agentId);
|
|
2058
|
+
if (!q2?.length) return null;
|
|
2059
|
+
this.queues.set(agentId, []);
|
|
2060
|
+
return this.format(q2);
|
|
2061
|
+
}
|
|
2062
|
+
canType(agentId) {
|
|
2063
|
+
const st = this.hub.status(agentId);
|
|
2064
|
+
return !!st && this.sessionUp.has(agentId) && st.session.state === "running" && st.state === "idle" && !this.typing.has(agentId) && Date.now() - (this.lastHook.get(agentId) ?? 0) >= QUIET_MS;
|
|
2065
|
+
}
|
|
2066
|
+
whyNot(agentId) {
|
|
2067
|
+
const st = this.hub.status(agentId);
|
|
2068
|
+
return JSON.stringify({ up: this.sessionUp.has(agentId), session: st?.session.state, state: st?.state, note: st?.note, typing: this.typing.has(agentId), quietMs: Date.now() - (this.lastHook.get(agentId) ?? 0) });
|
|
2069
|
+
}
|
|
2070
|
+
/** Types queued items into an idle agent. Safe to call any time. */
|
|
2071
|
+
async tryType(agentId) {
|
|
2072
|
+
const q2 = this.queues.get(agentId);
|
|
2073
|
+
if (!q2?.length) return;
|
|
2074
|
+
if (!this.canType(agentId)) {
|
|
2075
|
+
if (process.env.HQ_DEBUG) log.info(`inbox ${agentId}: holding ${q2.length} \u2014 ${this.whyNot(agentId)}`);
|
|
2076
|
+
return;
|
|
2077
|
+
}
|
|
2078
|
+
const items = q2.splice(0);
|
|
2079
|
+
this.typing.add(agentId);
|
|
2080
|
+
const typedAt = Date.now();
|
|
2081
|
+
try {
|
|
2082
|
+
if (!await this.keys.type(agentId, this.format(items))) {
|
|
2083
|
+
log.warn(`could not type into ${agentId}; keeping ${items.length} message(s) queued`);
|
|
2084
|
+
this.queues.set(agentId, [...items, ...this.queues.get(agentId) ?? []]);
|
|
2085
|
+
return;
|
|
2086
|
+
}
|
|
2087
|
+
await new Promise((r) => setTimeout(r, SUBMIT_CHECK_MS));
|
|
2088
|
+
if ((this.lastSubmit.get(agentId) ?? 0) < typedAt && this.hub.status(agentId)?.state === "idle") {
|
|
2089
|
+
log.warn(`${agentId}: no prompt submit after typing; pressing Enter again`);
|
|
2090
|
+
await this.keys.enter(agentId);
|
|
2091
|
+
}
|
|
2092
|
+
} finally {
|
|
2093
|
+
this.typing.delete(agentId);
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
format(items) {
|
|
2097
|
+
const lines = items.map(
|
|
2098
|
+
(i) => i.kind === "message" ? `[HQ] Message from ${this.hub.name(i.fromId)}: ${i.text}` : `[HQ] ${i.text}`
|
|
2099
|
+
);
|
|
2100
|
+
const fromAgent = items.some((i) => i.kind === "message" && i.fromId !== "you");
|
|
2101
|
+
return (lines.join(" \xB7 ") + (fromAgent ? " (Reply with the hq send_message tool only if a reply is needed.)" : "")).replace(/\s*\n\s*/g, " ");
|
|
2102
|
+
}
|
|
2103
|
+
};
|
|
2104
|
+
|
|
2105
|
+
// server/planner.ts
|
|
2106
|
+
var now2 = () => (/* @__PURE__ */ new Date()).toISOString();
|
|
2107
|
+
var Planner = class {
|
|
2108
|
+
constructor(hub, inbox) {
|
|
2109
|
+
this.hub = hub;
|
|
2110
|
+
this.inbox = inbox;
|
|
2111
|
+
}
|
|
2112
|
+
hub;
|
|
2113
|
+
inbox;
|
|
2114
|
+
seq = 0;
|
|
2115
|
+
/** Set by main: the user's action items (Team MCP `ask_user`). */
|
|
2116
|
+
actions = null;
|
|
2117
|
+
get snap() {
|
|
2118
|
+
return this.hub.snap;
|
|
2119
|
+
}
|
|
2120
|
+
task(id) {
|
|
2121
|
+
return this.snap.tasks.find((t2) => t2.id.toLowerCase() === id.trim().toLowerCase());
|
|
2122
|
+
}
|
|
2123
|
+
/** An agent by id or (case-insensitive) name; "lead" means the lead. */
|
|
2124
|
+
agentId(ref) {
|
|
2125
|
+
const r = ref.trim().toLowerCase();
|
|
2126
|
+
const team = this.snap.config.team;
|
|
2127
|
+
if (r === "lead") return team.find((a) => a.isLead)?.id ?? null;
|
|
2128
|
+
return team.find((a) => a.id === r || a.name.toLowerCase() === r)?.id ?? null;
|
|
2129
|
+
}
|
|
2130
|
+
defaultBoard() {
|
|
2131
|
+
const active = this.snap.boards.filter((b) => b.status === "active").sort((a, b) => a.order - b.order);
|
|
2132
|
+
if (active[0]) return active[0];
|
|
2133
|
+
return this.createBoard("Backlog", "active");
|
|
2134
|
+
}
|
|
2135
|
+
// ------------------------------------------------------------ boards
|
|
2136
|
+
createBoard(title, status = "planned") {
|
|
2137
|
+
const clean = title.trim().slice(0, 80) || "Board";
|
|
2138
|
+
const base = clean.split(/\s+/).map((w) => w[0]).join("").toUpperCase().replace(/[^A-Z0-9]/g, "").slice(0, 3) || "B";
|
|
2139
|
+
let id = base;
|
|
2140
|
+
for (let i = 2; this.snap.boards.some((b) => b.id === id); i++) id = `${base}${i}`;
|
|
2141
|
+
const board = { id, title: clean, status, order: this.snap.boards.length };
|
|
2142
|
+
this.snap.boards.push(board);
|
|
2143
|
+
this.hub.emit({ type: "board.updated", board: { ...board } });
|
|
2144
|
+
this.hub.planChanged();
|
|
2145
|
+
return board;
|
|
2146
|
+
}
|
|
2147
|
+
updateBoard(id, patch) {
|
|
2148
|
+
const board = this.snap.boards.find((b) => b.id === id);
|
|
2149
|
+
if (!board) throw new Error(`No board ${id}`);
|
|
2150
|
+
if (patch.title?.trim()) board.title = patch.title.trim().slice(0, 80);
|
|
2151
|
+
if (patch.status) board.status = patch.status;
|
|
2152
|
+
this.hub.emit({ type: "board.updated", board: { ...board } });
|
|
2153
|
+
this.hub.planChanged();
|
|
2154
|
+
return board;
|
|
2155
|
+
}
|
|
2156
|
+
// ------------------------------------------------------------ tasks
|
|
2157
|
+
createTask(input, byId) {
|
|
2158
|
+
const board = input.boardId ? this.snap.boards.find((b) => b.id === input.boardId) : this.defaultBoard();
|
|
2159
|
+
if (!board) throw new Error(`No board ${input.boardId}. Boards: ${this.snap.boards.map((b) => b.id).join(", ")}`);
|
|
2160
|
+
let n = this.snap.tasks.filter((t2) => t2.boardId === board.id).length + 1;
|
|
2161
|
+
let id = `${board.id}-T${String(n).padStart(2, "0")}`;
|
|
2162
|
+
while (this.task(id)) id = `${board.id}-T${String(++n).padStart(2, "0")}`;
|
|
2163
|
+
const task = {
|
|
2164
|
+
id,
|
|
2165
|
+
boardId: board.id,
|
|
2166
|
+
title: input.title.trim().slice(0, 120),
|
|
2167
|
+
ownerId: null,
|
|
2168
|
+
status: "todo",
|
|
2169
|
+
needs: input.needs ?? [],
|
|
2170
|
+
prNumber: null,
|
|
2171
|
+
description: input.description ?? "",
|
|
2172
|
+
files: input.files ?? [],
|
|
2173
|
+
doneWhen: input.doneWhen ?? "",
|
|
2174
|
+
note: null
|
|
2175
|
+
};
|
|
2176
|
+
this.snap.tasks.push(task);
|
|
2177
|
+
this.hub.emit({ type: "task.updated", task: { ...task } });
|
|
2178
|
+
this.hub.activity("task.created", byId === "you" ? null : byId, `${this.hub.name(byId)} added ${task.id} ${task.title}`);
|
|
2179
|
+
this.hub.planChanged();
|
|
2180
|
+
return task;
|
|
2181
|
+
}
|
|
2182
|
+
updateTask(id, patch) {
|
|
2183
|
+
const task = this.task(id);
|
|
2184
|
+
if (!task) throw new Error(`No task ${id}`);
|
|
2185
|
+
Object.assign(task, Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== void 0)));
|
|
2186
|
+
if (task.status === "done") {
|
|
2187
|
+
for (const a of this.snap.agents) if (a.currentTaskId === task.id) this.hub.setStatus(a.agentId, { currentTaskId: null });
|
|
2188
|
+
}
|
|
2189
|
+
this.hub.emit({ type: "task.updated", task: { ...task } });
|
|
2190
|
+
this.hub.planChanged();
|
|
2191
|
+
return task;
|
|
2192
|
+
}
|
|
2193
|
+
/** Gives a task to an agent and tells them to start. */
|
|
2194
|
+
assign(taskId, agentRef, byId) {
|
|
2195
|
+
const task = this.task(taskId);
|
|
2196
|
+
const agentId = this.agentId(agentRef);
|
|
2197
|
+
if (!task) throw new Error(`No task ${taskId}`);
|
|
2198
|
+
if (!agentId) throw new Error(`No agent "${agentRef}". Team: ${this.snap.config.team.map((a) => a.id).join(", ")}`);
|
|
2199
|
+
const agent = this.hub.cfg(agentId);
|
|
2200
|
+
task.ownerId = agentId;
|
|
2201
|
+
if (task.status === "done") task.status = "todo";
|
|
2202
|
+
this.hub.emit({ type: "task.updated", task: { ...task } });
|
|
2203
|
+
this.hub.setStatus(agentId, { currentTaskId: task.id });
|
|
2204
|
+
this.hub.planChanged();
|
|
2205
|
+
this.hub.activity("task.assigned", byId === "you" ? null : byId, `${this.hub.name(byId)} assigned ${task.id} ${task.title} to ${agent.name}`);
|
|
2206
|
+
const by = byId === "you" ? "The user (your human)" : this.hub.name(byId);
|
|
2207
|
+
const text = `${by} assigned you ${task.id} (${task.title}). Pull main, start ${task.id}. Call the hq get_my_task tool for the details.`;
|
|
2208
|
+
this.record(byId, agentId, `Start ${task.id}: ${task.title}`);
|
|
2209
|
+
this.inbox.push(agentId, { fromId: byId, kind: "assign", text });
|
|
2210
|
+
return task;
|
|
2211
|
+
}
|
|
2212
|
+
setStatus(agentId, status, note, prNumber) {
|
|
2213
|
+
const task = this.currentTask(agentId);
|
|
2214
|
+
if (!task) throw new Error("You have no task. Ask the lead for one.");
|
|
2215
|
+
task.status = status;
|
|
2216
|
+
task.note = note?.slice(0, 300) || null;
|
|
2217
|
+
if (prNumber) task.prNumber = prNumber;
|
|
2218
|
+
if (status === "done") this.hub.setStatus(agentId, { currentTaskId: null });
|
|
2219
|
+
else this.hub.setStatus(agentId, { currentTaskId: task.id });
|
|
2220
|
+
this.hub.emit({ type: "task.updated", task: { ...task } });
|
|
2221
|
+
this.hub.planChanged();
|
|
2222
|
+
const name = this.hub.name(agentId);
|
|
2223
|
+
const words = { "in-progress": "started", blocked: "is blocked on", "in-review": "sent for review", done: "finished" }[status];
|
|
2224
|
+
this.hub.activity(status === "blocked" ? "agent.needs-input" : "task.started", agentId, `${name} ${words} ${task.id}${note ? `: ${note}` : ""}`);
|
|
2225
|
+
const lead = this.snap.config.team.find((a) => a.isLead);
|
|
2226
|
+
if (lead && lead.id !== agentId && (status === "blocked" || status === "in-review" || status === "done"))
|
|
2227
|
+
this.message(agentId, lead.id, `${task.id} ${words}${note ? `: ${note}` : ""}${prNumber ? ` (PR #${prNumber})` : ""}`);
|
|
2228
|
+
return task;
|
|
2229
|
+
}
|
|
2230
|
+
currentTask(agentId) {
|
|
2231
|
+
const st = this.hub.status(agentId);
|
|
2232
|
+
const byCurrent = st?.currentTaskId ? this.task(st.currentTaskId) : void 0;
|
|
2233
|
+
if (byCurrent && byCurrent.ownerId === agentId) return byCurrent;
|
|
2234
|
+
const mine = this.snap.tasks.filter((t2) => t2.ownerId === agentId && t2.status !== "done");
|
|
2235
|
+
return mine.find((t2) => t2.status === "in-progress") ?? mine.find((t2) => t2.status === "todo") ?? mine[0];
|
|
2236
|
+
}
|
|
2237
|
+
// ------------------------------------------------------------ messages
|
|
2238
|
+
/** Records a message, shows it in the office, and delivers it. */
|
|
2239
|
+
message(fromId, toRef, text) {
|
|
2240
|
+
const toId = this.agentId(toRef);
|
|
2241
|
+
if (!toId) throw new Error(`No agent "${toRef}". Team: ${this.snap.config.team.map((a) => a.id).join(", ")}`);
|
|
2242
|
+
const clean = text.trim().slice(0, 2e3);
|
|
2243
|
+
if (!clean) throw new Error("Empty message");
|
|
2244
|
+
this.record(fromId, toId, clean);
|
|
2245
|
+
this.hub.activity("message", fromId === "you" ? null : fromId, `${this.hub.name(fromId)} \u2192 ${this.hub.name(toId)}: ${clean.slice(0, 80)}`);
|
|
2246
|
+
this.inbox.push(toId, { fromId, kind: "message", text: clean });
|
|
2247
|
+
return toId;
|
|
2248
|
+
}
|
|
2249
|
+
/** A notice from HQ itself (review results, merges) to one agent. */
|
|
2250
|
+
notify(agentId, text) {
|
|
2251
|
+
if (!this.hub.cfg(agentId)) return;
|
|
2252
|
+
this.record("hq", agentId, text);
|
|
2253
|
+
this.inbox.push(agentId, { fromId: "hq", kind: "notice", text });
|
|
2254
|
+
}
|
|
2255
|
+
record(fromId, toId, text) {
|
|
2256
|
+
this.seq += 1;
|
|
2257
|
+
const msg = { id: `m${Date.now().toString(36)}${this.seq}`, at: now2(), fromId, toId, text };
|
|
2258
|
+
this.snap.messages = [msg, ...this.snap.messages].slice(0, 100);
|
|
2259
|
+
this.hub.emit({ type: "message", message: msg });
|
|
2260
|
+
}
|
|
2261
|
+
};
|
|
2262
|
+
|
|
2263
|
+
// server/actionItems.ts
|
|
2264
|
+
import { existsSync as existsSync5, readFileSync as readFileSync5, renameSync as renameSync2, writeFileSync as writeFileSync6 } from "node:fs";
|
|
2265
|
+
import { join as join8 } from "node:path";
|
|
2266
|
+
|
|
2267
|
+
// server/lib/json.ts
|
|
2268
|
+
function parseCliJson(text) {
|
|
2269
|
+
const starts = [text.indexOf("{"), text.indexOf("[")].filter((i) => i !== -1);
|
|
2270
|
+
if (!starts.length) return null;
|
|
2271
|
+
const start = Math.min(...starts);
|
|
2272
|
+
const end = text.lastIndexOf(text[start] === "{" ? "}" : "]");
|
|
2273
|
+
if (end <= start) return null;
|
|
2274
|
+
try {
|
|
2275
|
+
return JSON.parse(text.slice(start, end + 1));
|
|
2276
|
+
} catch {
|
|
2277
|
+
return null;
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
function sandboxList(text) {
|
|
2281
|
+
const data = parseCliJson(text);
|
|
2282
|
+
if (data === null) return null;
|
|
2283
|
+
return Array.isArray(data) ? data : data.sandboxes ?? [];
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
// server/actionItems.ts
|
|
2287
|
+
var KEEP_CLOSED = 100;
|
|
2288
|
+
var ActionItems = class {
|
|
2289
|
+
constructor(hub, planner) {
|
|
2290
|
+
this.hub = hub;
|
|
2291
|
+
this.planner = planner;
|
|
2292
|
+
this.file = join8(projectHome(hub.slug), "action-items.json");
|
|
2293
|
+
const saved = existsSync5(this.file) ? parseCliJson(readFileSync5(this.file, "utf8")) : null;
|
|
2294
|
+
hub.snap.actionItems = Array.isArray(saved) ? saved : [];
|
|
2295
|
+
}
|
|
2296
|
+
hub;
|
|
2297
|
+
planner;
|
|
2298
|
+
file;
|
|
2299
|
+
seq = 0;
|
|
2300
|
+
get items() {
|
|
2301
|
+
return this.hub.snap.actionItems ??= [];
|
|
2302
|
+
}
|
|
2303
|
+
save() {
|
|
2304
|
+
const open = this.items.filter((i) => i.status === "open");
|
|
2305
|
+
const closed = this.items.filter((i) => i.status !== "open").slice(0, KEEP_CLOSED);
|
|
2306
|
+
this.hub.snap.actionItems = [...open, ...closed].sort((a, b) => b.at.localeCompare(a.at));
|
|
2307
|
+
const tmp = `${this.file}.${process.pid}.tmp`;
|
|
2308
|
+
writeFileSync6(tmp, JSON.stringify(this.hub.snap.actionItems, null, 2));
|
|
2309
|
+
renameSync2(tmp, this.file);
|
|
2310
|
+
}
|
|
2311
|
+
add(fromId, title, details, waiting) {
|
|
2312
|
+
const clean = title.trim().replace(/\s+/g, " ").slice(0, 160);
|
|
2313
|
+
if (!clean) throw new Error("Give the action item a short title.");
|
|
2314
|
+
const item = {
|
|
2315
|
+
id: `ai${Date.now().toString(36)}${++this.seq}`,
|
|
2316
|
+
fromId,
|
|
2317
|
+
taskId: this.planner.currentTask(fromId)?.id ?? null,
|
|
2318
|
+
title: clean,
|
|
2319
|
+
details: details.trim().slice(0, 4e3),
|
|
2320
|
+
waiting,
|
|
2321
|
+
status: "open",
|
|
2322
|
+
note: null,
|
|
2323
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2324
|
+
closedAt: null
|
|
2325
|
+
};
|
|
2326
|
+
this.items.unshift(item);
|
|
2327
|
+
this.save();
|
|
2328
|
+
this.hub.emit({ type: "action.updated", item: { ...item } });
|
|
2329
|
+
this.hub.activity("agent.needs-input", fromId, `${this.hub.name(fromId)} added an action item${waiting ? " (waiting)" : ""}: ${clean}`);
|
|
2330
|
+
return item;
|
|
2331
|
+
}
|
|
2332
|
+
resolve(id, decision, note) {
|
|
2333
|
+
const item = this.items.find((i) => i.id === id);
|
|
2334
|
+
if (!item || item.status !== "open") return;
|
|
2335
|
+
item.status = decision;
|
|
2336
|
+
item.note = note?.trim().slice(0, 1e3) || null;
|
|
2337
|
+
item.closedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2338
|
+
this.save();
|
|
2339
|
+
this.hub.emit({ type: "action.updated", item: { ...item } });
|
|
2340
|
+
const n = item.note ? ` Note from the user: ${item.note}` : "";
|
|
2341
|
+
if (decision === "done") this.planner.notify(item.fromId, `The user did your action item "${item.title}".${n} Continue with it.`);
|
|
2342
|
+
else this.planner.notify(item.fromId, `The user dismissed your action item "${item.title}".${n} Continue without it, or find another way.`);
|
|
2343
|
+
}
|
|
2344
|
+
};
|
|
2345
|
+
|
|
2346
|
+
// server/sbx.ts
|
|
2347
|
+
import { chmodSync, existsSync as existsSync7, readFileSync as readFileSync7, writeFileSync as writeFileSync7 } from "node:fs";
|
|
2348
|
+
import { join as join9 } from "node:path";
|
|
2349
|
+
|
|
2350
|
+
// server/setup.ts
|
|
2351
|
+
import { existsSync as existsSync6, readFileSync as readFileSync6 } from "node:fs";
|
|
2352
|
+
import { arch, cpus, platform, release, totalmem } from "node:os";
|
|
2353
|
+
|
|
2354
|
+
// server/backends/pty.ts
|
|
2355
|
+
import { spawn as spawnPty } from "@lydell/node-pty";
|
|
2356
|
+
import xtermHeadless from "@xterm/headless";
|
|
2357
|
+
var { Terminal } = xtermHeadless;
|
|
2358
|
+
var TERM_ENV = { ...process.env, TERM: "xterm-256color", COLORTERM: "truecolor" };
|
|
2359
|
+
var procs = /* @__PURE__ */ new Map();
|
|
2360
|
+
async function resolveCommand(file, args) {
|
|
2361
|
+
if (process.platform !== "win32" || /[\\/]/.test(file)) return { file, args };
|
|
2362
|
+
const r = await run("where", [file], { timeoutMs: 5e3 });
|
|
2363
|
+
const found = r.stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
2364
|
+
const exe = found.find((p) => /\.exe$/i.test(p));
|
|
2365
|
+
if (exe) return { file: exe, args };
|
|
2366
|
+
const script = found.find((p) => /\.(cmd|bat)$/i.test(p));
|
|
2367
|
+
if (script) return { file: "cmd.exe", args: ["/d", "/s", "/c", script, ...args] };
|
|
2368
|
+
throw new Error(`${file} was not found on PATH.`);
|
|
2369
|
+
}
|
|
2370
|
+
var ptyBackend = {
|
|
2371
|
+
kind: "pty",
|
|
2372
|
+
persistent: false,
|
|
2373
|
+
async exists(name) {
|
|
2374
|
+
return procs.has(name);
|
|
2375
|
+
},
|
|
2376
|
+
async create(name, cwd, spec, size) {
|
|
2377
|
+
if (procs.has(name)) return;
|
|
2378
|
+
const { file, args } = await resolveCommand(spec.file, spec.args);
|
|
2379
|
+
const pty = spawnPty(file, args, { name: "xterm-256color", cols: size.cols, rows: size.rows, cwd, env: TERM_ENV });
|
|
2380
|
+
const screen = new Terminal({ cols: size.cols, rows: size.rows, scrollback: 300, allowProposedApi: true });
|
|
2381
|
+
const proc = { pty, screen, exit: { dead: false, exitStatus: null }, views: /* @__PURE__ */ new Set(), ends: /* @__PURE__ */ new Set() };
|
|
2382
|
+
pty.onData((d) => {
|
|
2383
|
+
screen.write(d);
|
|
2384
|
+
for (const view of proc.views) view(d);
|
|
2385
|
+
});
|
|
2386
|
+
pty.onExit(({ exitCode }) => {
|
|
2387
|
+
proc.exit = { dead: true, exitStatus: exitCode };
|
|
2388
|
+
});
|
|
2389
|
+
procs.set(name, proc);
|
|
2390
|
+
},
|
|
2391
|
+
async kill(name) {
|
|
2392
|
+
const proc = procs.get(name);
|
|
2393
|
+
if (!proc) return;
|
|
2394
|
+
procs.delete(name);
|
|
2395
|
+
if (!proc.exit.dead) proc.pty.kill();
|
|
2396
|
+
proc.screen.dispose();
|
|
2397
|
+
for (const end of proc.ends) end();
|
|
2398
|
+
},
|
|
2399
|
+
attach(name, size, onData, onEnd) {
|
|
2400
|
+
const proc = procs.get(name);
|
|
2401
|
+
if (!proc) {
|
|
2402
|
+
queueMicrotask(onEnd);
|
|
2403
|
+
return { write() {
|
|
2404
|
+
}, resize() {
|
|
2405
|
+
}, close() {
|
|
2406
|
+
} };
|
|
2407
|
+
}
|
|
2408
|
+
proc.views.add(onData);
|
|
2409
|
+
proc.ends.add(onEnd);
|
|
2410
|
+
const resize = (cols, rows) => {
|
|
2411
|
+
if (proc.exit.dead) return;
|
|
2412
|
+
proc.pty.resize(cols, rows);
|
|
2413
|
+
proc.screen.resize(cols, rows);
|
|
2414
|
+
};
|
|
2415
|
+
resize(size.cols, size.rows);
|
|
2416
|
+
return {
|
|
2417
|
+
write: (d) => !proc.exit.dead && proc.pty.write(d),
|
|
2418
|
+
resize,
|
|
2419
|
+
close: () => {
|
|
2420
|
+
proc.views.delete(onData);
|
|
2421
|
+
proc.ends.delete(onEnd);
|
|
2422
|
+
}
|
|
2423
|
+
};
|
|
2424
|
+
},
|
|
2425
|
+
async type(name, text) {
|
|
2426
|
+
const proc = procs.get(name);
|
|
2427
|
+
if (!proc || proc.exit.dead) return false;
|
|
2428
|
+
proc.pty.write(text);
|
|
2429
|
+
return true;
|
|
2430
|
+
},
|
|
2431
|
+
async enter(name) {
|
|
2432
|
+
const proc = procs.get(name);
|
|
2433
|
+
if (!proc || proc.exit.dead) return false;
|
|
2434
|
+
proc.pty.write("\r");
|
|
2435
|
+
return true;
|
|
2436
|
+
},
|
|
2437
|
+
async exitInfo(name) {
|
|
2438
|
+
return procs.get(name)?.exit ?? null;
|
|
2439
|
+
},
|
|
2440
|
+
async screenText(name, lines) {
|
|
2441
|
+
const proc = procs.get(name);
|
|
2442
|
+
if (!proc) return "";
|
|
2443
|
+
await new Promise((r) => proc.screen.write("", r));
|
|
2444
|
+
const buf = proc.screen.buffer.active;
|
|
2445
|
+
const out = [];
|
|
2446
|
+
for (let i = 0; i < buf.length; i++) out.push(buf.getLine(i)?.translateToString(true).trimEnd() ?? "");
|
|
2447
|
+
return out.filter(Boolean).slice(-lines).join("\n");
|
|
2448
|
+
},
|
|
2449
|
+
shutdown() {
|
|
2450
|
+
for (const proc of procs.values()) if (!proc.exit.dead) proc.pty.kill();
|
|
2451
|
+
procs.clear();
|
|
2452
|
+
}
|
|
2453
|
+
};
|
|
2454
|
+
|
|
2455
|
+
// server/backends/tmux.ts
|
|
2456
|
+
import { spawn as spawnPty2 } from "@lydell/node-pty";
|
|
2457
|
+
|
|
2458
|
+
// server/backends/tmuxCli.ts
|
|
2459
|
+
async function hasSession(name) {
|
|
2460
|
+
return (await run("tmux", ["has-session", "-t", `=${name}`], { timeoutMs: 5e3 })).ok;
|
|
2461
|
+
}
|
|
2462
|
+
async function newSession(name, cwd, command, env = {}) {
|
|
2463
|
+
const args = ["new-session", "-d", "-s", name, "-x", "200", "-y", "50", "-c", cwd];
|
|
2464
|
+
for (const [k, v] of Object.entries(env)) args.push("-e", `${k}=${v}`);
|
|
2465
|
+
args.push(command);
|
|
2466
|
+
const r = await run("tmux", args, { timeoutMs: 1e4 });
|
|
2467
|
+
if (!r.ok) throw new Error(`tmux: ${r.stderr.trim()}`);
|
|
2468
|
+
await run("tmux", ["set-option", "-t", name, "remain-on-exit", "on"], { timeoutMs: 5e3 });
|
|
2469
|
+
await run("tmux", ["set-option", "-t", name, "mouse", "on"], { timeoutMs: 5e3 });
|
|
2470
|
+
await run("tmux", ["set-option", "-t", name, "status", "off"], { timeoutMs: 5e3 });
|
|
2471
|
+
await run("tmux", ["set-option", "-t", name, "focus-events", "on"], { timeoutMs: 5e3 });
|
|
2472
|
+
}
|
|
2473
|
+
async function killSession(name) {
|
|
2474
|
+
await run("tmux", ["kill-session", "-t", `=${name}`], { timeoutMs: 5e3 });
|
|
2475
|
+
}
|
|
2476
|
+
async function paneInfo(name) {
|
|
2477
|
+
const r = await run("tmux", ["display-message", "-p", "-t", `=${name}:`, "#{pane_dead} #{pane_dead_status}"], { timeoutMs: 5e3 });
|
|
2478
|
+
if (!r.ok) return null;
|
|
2479
|
+
const [dead, status] = r.stdout.trim().split(" ");
|
|
2480
|
+
return { dead: dead === "1", exitStatus: status ? Number(status) : null };
|
|
2481
|
+
}
|
|
2482
|
+
async function tailText(name, n = 6) {
|
|
2483
|
+
const r = await run("tmux", ["capture-pane", "-p", "-t", `=${name}:`], { timeoutMs: 5e3 });
|
|
2484
|
+
return r.stdout.split("\n").map((l) => l.trimEnd()).filter(Boolean).slice(-n).join("\n");
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2487
|
+
// server/backends/tmux.ts
|
|
2488
|
+
var TERM_ENV2 = { ...process.env, TERM: "xterm-256color", COLORTERM: "truecolor" };
|
|
2489
|
+
var q = (s) => /^[A-Za-z0-9_\-.,:/=@%+]+$/.test(s) ? s : shq(s);
|
|
2490
|
+
var loadEnvFile = (quotedPath) => (
|
|
2491
|
+
// CRLF files: drop the \r. Lines whose key isn't a valid name are skipped (export would stop the shell).
|
|
2492
|
+
// Scratch names are unlikely to clash with app variables and are unset afterwards; the CR is
|
|
2493
|
+
// computed per line, so nothing in the file can change it.
|
|
2494
|
+
`while IFS= read -r __jsmhq_l || [ -n "$__jsmhq_l" ]; do __jsmhq_l=\${__jsmhq_l%"$(printf '\\r')"}; __jsmhq_k=\${__jsmhq_l%%=*}; case "$__jsmhq_l" in *=*) case "$__jsmhq_k" in ""|[!A-Za-z_]*|*[!A-Za-z0-9_]*) ;; *) export "$__jsmhq_l";; esac;; esac; done < ${quotedPath}; unset __jsmhq_l __jsmhq_k`
|
|
2495
|
+
);
|
|
2496
|
+
function shellCommand(spec) {
|
|
2497
|
+
const load = spec.envFile ? `${loadEnvFile(shq(spec.envFile))} && ` : "";
|
|
2498
|
+
return load + [spec.file, ...spec.args].map(q).join(" ");
|
|
2499
|
+
}
|
|
2500
|
+
var tmuxBackend = {
|
|
2501
|
+
kind: "tmux",
|
|
2502
|
+
persistent: true,
|
|
2503
|
+
exists: (name) => hasSession(name),
|
|
2504
|
+
async create(name, cwd, spec) {
|
|
2505
|
+
await newSession(name, cwd, shellCommand(spec));
|
|
2506
|
+
},
|
|
2507
|
+
kill: (name) => killSession(name),
|
|
2508
|
+
attach(name, size, onData, onEnd) {
|
|
2509
|
+
const pty = spawnPty2("tmux", ["attach-session", "-t", `=${name}`], { name: "xterm-256color", cols: size.cols, rows: size.rows, env: TERM_ENV2 });
|
|
2510
|
+
pty.onData(onData);
|
|
2511
|
+
pty.onExit(onEnd);
|
|
2512
|
+
return { write: (d) => pty.write(d), resize: (c, r) => pty.resize(c, r), close: () => pty.kill() };
|
|
2513
|
+
},
|
|
2514
|
+
async type(name, text) {
|
|
2515
|
+
if (!await hasSession(name)) return false;
|
|
2516
|
+
return (await run("tmux", ["send-keys", "-t", `=${name}:`, "-l", text], { timeoutMs: 5e3 })).ok;
|
|
2517
|
+
},
|
|
2518
|
+
async enter(name) {
|
|
2519
|
+
return (await run("tmux", ["send-keys", "-t", `=${name}:`, "Enter"], { timeoutMs: 5e3 })).ok;
|
|
2520
|
+
},
|
|
2521
|
+
exitInfo: (name) => paneInfo(name),
|
|
2522
|
+
screenText: (name, lines) => tailText(name, lines),
|
|
2523
|
+
shutdown() {
|
|
2524
|
+
}
|
|
2525
|
+
};
|
|
2526
|
+
|
|
2527
|
+
// server/backends/index.ts
|
|
2528
|
+
function usesTmux() {
|
|
2529
|
+
const forced = process.env.HQ_SESSION_BACKEND;
|
|
2530
|
+
if (forced === "pty" || forced === "tmux") return forced === "tmux";
|
|
2531
|
+
return process.platform !== "win32";
|
|
2532
|
+
}
|
|
2533
|
+
function sessionBackend() {
|
|
2534
|
+
return usesTmux() ? tmuxBackend : ptyBackend;
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2537
|
+
// server/setup.ts
|
|
2538
|
+
var SBX_DOCS = "https://docs.docker.com/ai/sandboxes/install/";
|
|
2539
|
+
var os = platform();
|
|
2540
|
+
var fix = (command, runnable, docs) => ({ command, runnable, docs });
|
|
2541
|
+
async function checkOs() {
|
|
2542
|
+
const base = { id: "os", label: "Machine supports sandboxes", neededFor: "sandbox", fix: fix("", false, SBX_DOCS) };
|
|
2543
|
+
if (os === "darwin") {
|
|
2544
|
+
const v = (await run("sw_vers", ["-productVersion"])).stdout.trim();
|
|
2545
|
+
const major = Number(v.split(".")[0]);
|
|
2546
|
+
if (arch() !== "arm64") return { ...base, status: "unsupported", detail: `macOS ${v} on Intel. Sandboxes need Apple silicon \u2014 use Local mode.` };
|
|
2547
|
+
if (major < 14) return { ...base, status: "unsupported", detail: `macOS ${v}. Sandboxes need macOS 14 (Sonoma) or later.` };
|
|
2548
|
+
return { ...base, status: "ok", detail: `macOS ${v} \xB7 Apple silicon`, fix: null };
|
|
2549
|
+
}
|
|
2550
|
+
if (os === "win32") {
|
|
2551
|
+
const build = Number(release().split(".")[2] ?? 0);
|
|
2552
|
+
return build >= 22e3 ? { ...base, status: "ok", detail: "Windows 11 (needs Windows Hypervisor Platform enabled)", fix: null } : { ...base, status: "unsupported", detail: "Windows 10. Sandboxes need Windows 11 \u2014 use Local mode." };
|
|
2553
|
+
}
|
|
2554
|
+
const osRelease = existsSync6("/etc/os-release") ? readFileSync6("/etc/os-release", "utf8") : "";
|
|
2555
|
+
const id = osRelease.match(/^ID=(.*)$/m)?.[1]?.replace(/"/g, "");
|
|
2556
|
+
const ver = Number(osRelease.match(/^VERSION_ID="?([\d.]+)/m)?.[1] ?? 0);
|
|
2557
|
+
if (id === "ubuntu" && ver >= 24.04) return { ...base, status: "ok", detail: `Ubuntu ${ver} (needs KVM)`, fix: null };
|
|
2558
|
+
return { ...base, status: "warning", detail: `${id ?? "Linux"} ${ver || ""} \u2014 Docker documents Ubuntu 24.04+ with KVM.` };
|
|
2559
|
+
}
|
|
2560
|
+
async function checkSbx(brew) {
|
|
2561
|
+
const base = { id: "sbx", label: "Docker Sandboxes (sbx)", neededFor: "sandbox" };
|
|
2562
|
+
const v = await run("sbx", ["version"], { timeoutMs: 8e3 });
|
|
2563
|
+
if (v.ok) return { ...base, status: "ok", detail: v.stdout.trim().split(/\s+/)[2] ?? v.stdout.trim(), fix: null };
|
|
2564
|
+
const cmd = os === "darwin" ? "brew trust docker/tap && brew install docker/tap/sbx" : os === "win32" ? "winget install -h Docker.sbx" : "curl -fsSL https://get.docker.com | sudo SBX=1 sh";
|
|
2565
|
+
const runnable = os === "darwin" ? brew : os === "win32";
|
|
2566
|
+
return { ...base, status: "missing", detail: "Not installed.", fix: fix(cmd, runnable, SBX_DOCS) };
|
|
2567
|
+
}
|
|
2568
|
+
var POLICY_NOT_INITIALIZED = /not been initialized|sbx policy init/i;
|
|
2569
|
+
async function policyVerdict(host) {
|
|
2570
|
+
const r = await run("sbx", ["policy", "check", "network", host], { timeoutMs: 1e4 });
|
|
2571
|
+
const out = `${r.stdout}
|
|
2572
|
+
${r.stderr}`.replace(/\x1b(\[[0-9;?]*[ -\/]*[@-~]|\][^\x07\x1b]*(\x07|\x1b\\)|[@-_])/g, "");
|
|
2573
|
+
const m = out.match(/\b(Allowed|Denied):/);
|
|
2574
|
+
return { verdict: m ? m[1] === "Allowed" ? "allowed" : "denied" : null, out };
|
|
2575
|
+
}
|
|
2576
|
+
async function checkSbxPolicy(loginOk) {
|
|
2577
|
+
const base = { id: "sbx-policy", label: "Sandbox network policy", neededFor: "sandbox" };
|
|
2578
|
+
if (!loginOk) return { ...base, status: "missing", detail: "Waiting for Docker sign-in.", fix: null };
|
|
2579
|
+
const { verdict, out } = await policyVerdict("registry.npmjs.org");
|
|
2580
|
+
if (POLICY_NOT_INITIALIZED.test(out))
|
|
2581
|
+
return {
|
|
2582
|
+
...base,
|
|
2583
|
+
status: "missing",
|
|
2584
|
+
detail: "Not set yet. Choose what sandboxes can reach once: balanced (recommended: AI services, package registries, GitHub), allow-all, or deny-all. Change it later with sbx policy reset.",
|
|
2585
|
+
fix: fix("sbx policy init balanced", true, SBX_DOCS)
|
|
2586
|
+
};
|
|
2587
|
+
if (!verdict) return { ...base, status: "warning", detail: out.trim().split("\n")[0] || "sbx did not respond.", fix: fix("sbx diagnose", true, SBX_DOCS) };
|
|
2588
|
+
const open = (await policyVerdict("hq-probe.invalid")).verdict === "allowed";
|
|
2589
|
+
const name = open ? "Allow all" : verdict === "allowed" ? "Balanced" : "Deny all";
|
|
2590
|
+
return { ...base, status: "ok", detail: `${name}. HQ adds only the rule that lets sandboxes reach it.`, fix: null };
|
|
2591
|
+
}
|
|
2592
|
+
async function checkSbxLogin(sbxOk) {
|
|
2593
|
+
const base = { id: "sbx-login", label: "Signed in to Docker", neededFor: "sandbox" };
|
|
2594
|
+
if (!sbxOk) return { ...base, status: "missing", detail: "Waiting for sbx.", fix: null };
|
|
2595
|
+
const r = await run("sbx", ["ls", "--json"], { timeoutMs: 1e4 });
|
|
2596
|
+
const list = r.ok ? sandboxList(r.stdout) : null;
|
|
2597
|
+
if (r.ok) {
|
|
2598
|
+
const n = list?.length;
|
|
2599
|
+
return { ...base, status: "ok", detail: n === void 0 ? "Signed in" : `Signed in \xB7 ${n} sandbox${n === 1 ? "" : "es"} on this machine`, fix: null };
|
|
2600
|
+
}
|
|
2601
|
+
const msg = (r.stderr || r.stdout).trim();
|
|
2602
|
+
const loggedOut = /log ?in|sign ?in|unauthori[sz]ed|not authenticated/i.test(msg);
|
|
2603
|
+
return {
|
|
2604
|
+
...base,
|
|
2605
|
+
status: loggedOut ? "missing" : "warning",
|
|
2606
|
+
detail: loggedOut ? "Not signed in. Sign-in opens your browser." : msg.split("\n")[0] || "sbx did not respond.",
|
|
2607
|
+
fix: fix(loggedOut ? "sbx login" : "sbx diagnose", true, SBX_DOCS)
|
|
2608
|
+
};
|
|
2609
|
+
}
|
|
2610
|
+
async function checkTmux(brew) {
|
|
2611
|
+
const base = { id: "tmux", label: "tmux (keeps agents running if HQ closes)", neededFor: "all" };
|
|
2612
|
+
if (!usesTmux())
|
|
2613
|
+
return { ...base, label: "Agent sessions", status: "ok", detail: "HQ runs agents itself on Windows (no tmux needed). They stop when HQ stops; sandboxes and folders are kept.", fix: null };
|
|
2614
|
+
const v = await run("tmux", ["-V"], { timeoutMs: 5e3 });
|
|
2615
|
+
if (v.ok) return { ...base, status: "ok", detail: v.stdout.trim(), fix: null };
|
|
2616
|
+
if (os === "win32") return { ...base, status: "unsupported", detail: "tmux needs WSL on Windows. Run HQ inside WSL.", fix: fix("wsl --install", false, "https://learn.microsoft.com/windows/wsl/install") };
|
|
2617
|
+
return {
|
|
2618
|
+
...base,
|
|
2619
|
+
status: "missing",
|
|
2620
|
+
detail: "Not installed.",
|
|
2621
|
+
fix: os === "darwin" ? fix("brew install tmux", brew, "https://github.com/tmux/tmux/wiki/Installing") : fix("sudo apt install tmux", false, "https://github.com/tmux/tmux/wiki/Installing")
|
|
2622
|
+
};
|
|
2623
|
+
}
|
|
2624
|
+
async function checkClaude() {
|
|
2625
|
+
const base = { id: "claude", label: "Claude Code (for Local mode)", neededFor: "local" };
|
|
2626
|
+
const v = await run("claude", ["--version"], { timeoutMs: 8e3 });
|
|
2627
|
+
if (v.ok) return { ...base, status: "ok", detail: v.stdout.trim(), fix: null };
|
|
2628
|
+
return {
|
|
2629
|
+
...base,
|
|
2630
|
+
status: "missing",
|
|
2631
|
+
detail: "Not installed. Only needed for agents in Local mode.",
|
|
2632
|
+
fix: fix(os === "win32" ? "irm https://claude.ai/install.ps1 | iex" : "curl -fsSL https://claude.ai/install.sh | bash", false, "https://docs.claude.com/en/docs/claude-code/setup")
|
|
2633
|
+
};
|
|
2634
|
+
}
|
|
2635
|
+
async function checkGh(brew) {
|
|
2636
|
+
const base = { id: "gh", label: "GitHub CLI (reads PRs and reviews)", neededFor: "github" };
|
|
2637
|
+
if (!await has("gh"))
|
|
2638
|
+
return {
|
|
2639
|
+
...base,
|
|
2640
|
+
status: "missing",
|
|
2641
|
+
detail: "Not installed.",
|
|
2642
|
+
fix: fix(os === "darwin" ? "brew install gh" : os === "win32" ? "winget install --id GitHub.cli" : "sudo apt install gh", os === "darwin" ? brew : os === "win32", "https://cli.github.com")
|
|
2643
|
+
};
|
|
2644
|
+
const r = await run("gh", ["auth", "status"], { timeoutMs: 1e4 });
|
|
2645
|
+
const out = r.stdout + r.stderr;
|
|
2646
|
+
if (r.ok) return { ...base, status: "ok", detail: `Signed in as ${out.match(/account (\S+)/)?.[1] ?? "you"}`, fix: null };
|
|
2647
|
+
return { ...base, status: "missing", detail: "Not signed in.", fix: fix("gh auth login", true, "https://cli.github.com/manual/gh_auth_login") };
|
|
2648
|
+
}
|
|
2649
|
+
function checkRemote(repo) {
|
|
2650
|
+
const base = { id: "gh-remote", label: "GitHub remote", neededFor: "github" };
|
|
2651
|
+
if (repo.github) return { ...base, status: "ok", detail: repo.github, fix: null };
|
|
2652
|
+
return {
|
|
2653
|
+
...base,
|
|
2654
|
+
status: "warning",
|
|
2655
|
+
detail: repo.originUrl ? `origin is ${repo.originUrl} (not GitHub)` : "No origin remote. Agents can't push or open PRs.",
|
|
2656
|
+
fix: fix("gh repo create --source . --private --push", false, "https://cli.github.com/manual/gh_repo_create")
|
|
2657
|
+
};
|
|
2658
|
+
}
|
|
2659
|
+
async function checkGithubSecret(sbxOk) {
|
|
2660
|
+
const base = { id: "github-secret", label: "GitHub access inside sandboxes", neededFor: "sandbox" };
|
|
2661
|
+
if (!sbxOk) return { ...base, status: "missing", detail: "Waiting for sbx.", fix: null };
|
|
2662
|
+
const r = await run("sbx", ["secret", "ls"], { timeoutMs: 1e4 });
|
|
2663
|
+
if (r.ok && /\bgithub\b/.test(r.stdout)) return { ...base, status: "ok", detail: "Stored in Docker's secret store; injected by the proxy.", fix: null };
|
|
2664
|
+
return {
|
|
2665
|
+
...base,
|
|
2666
|
+
status: "missing",
|
|
2667
|
+
detail: "Agents can't push or open PRs until this is set.",
|
|
2668
|
+
fix: fix("sbx secret set github --command 'gh auth token'", true, "https://docs.docker.com/ai/sandboxes/get-started/")
|
|
2669
|
+
};
|
|
2670
|
+
}
|
|
2671
|
+
async function checkCodex() {
|
|
2672
|
+
const base = { id: "codex", label: "Codex (for Codex agents in Local mode)", neededFor: "local" };
|
|
2673
|
+
const v = await run("codex", ["--version"], { timeoutMs: 8e3 });
|
|
2674
|
+
if (v.ok) return { ...base, status: "ok", detail: v.stdout.trim(), fix: null };
|
|
2675
|
+
return { ...base, status: "missing", detail: "Not installed. Only needed for Codex agents in Local mode (sandboxes have it).", fix: fix("npm install -g @openai/codex", false, "https://developers.openai.com/codex/cli") };
|
|
2676
|
+
}
|
|
2677
|
+
async function checkOpenAiSecret(sbxOk) {
|
|
2678
|
+
const base = { id: "openai-secret", label: "OpenAI sign-in for Codex agents", neededFor: "sandbox" };
|
|
2679
|
+
if (!sbxOk) return { ...base, status: "missing", detail: "Waiting for sbx.", fix: null };
|
|
2680
|
+
const r = await run("sbx", ["secret", "ls", "--json"], { timeoutMs: 1e4 });
|
|
2681
|
+
let has2 = false;
|
|
2682
|
+
try {
|
|
2683
|
+
has2 = (parseCliJson(r.stdout)?.secrets ?? []).some((s) => s.name === "openai" && s.scope === "global");
|
|
2684
|
+
} catch {
|
|
2685
|
+
}
|
|
2686
|
+
if (has2) return { ...base, status: "ok", detail: "Stored in Docker's secret store.", fix: null };
|
|
2687
|
+
return { ...base, status: "missing", detail: "Codex agents can't sign in until this is set. Opens your browser to sign in with ChatGPT.", fix: fix("sbx secret set openai --oauth", true, "https://docs.docker.com/ai/sandboxes/") };
|
|
2688
|
+
}
|
|
2689
|
+
async function runSetupChecks(repo, team = []) {
|
|
2690
|
+
const brew = os === "darwin" && await has("brew");
|
|
2691
|
+
const [osCheck, sbx, tmux, claude2, gh] = await Promise.all([checkOs(), checkSbx(brew), checkTmux(brew), checkClaude(), checkGh(brew)]);
|
|
2692
|
+
const sbxOk = sbx.status === "ok";
|
|
2693
|
+
const [login, secret] = await Promise.all([checkSbxLogin(sbxOk), checkGithubSecret(sbxOk)]);
|
|
2694
|
+
const policy = await checkSbxPolicy(login.status === "ok");
|
|
2695
|
+
const checks = [osCheck, sbx, login, policy, secret, tmux, claude2, gh, checkRemote(repo)];
|
|
2696
|
+
const codexAgents = team.filter((a) => a.cli === "codex");
|
|
2697
|
+
if (codexAgents.some((a) => a.mode === "sandbox")) checks.splice(5, 0, await checkOpenAiSecret(sbxOk));
|
|
2698
|
+
if (codexAgents.some((a) => a.mode === "local")) checks.push(await checkCodex());
|
|
2699
|
+
const ok2 = (c) => c.status === "ok";
|
|
2700
|
+
return {
|
|
2701
|
+
machine: { cpus: cpus().length, memoryGb: Math.round(totalmem() / 1024 ** 3), persistentAgents: usesTmux() },
|
|
2702
|
+
checks,
|
|
2703
|
+
sandboxReady: osCheck.status !== "unsupported" && ok2(sbx) && ok2(login) && ok2(policy) && ok2(tmux),
|
|
2704
|
+
localReady: ok2(tmux) && ok2(claude2),
|
|
2705
|
+
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2706
|
+
};
|
|
2707
|
+
}
|
|
2708
|
+
|
|
2709
|
+
// server/sbx.ts
|
|
2710
|
+
var key2 = (decision, resource) => `${decision} ${resource}`;
|
|
2711
|
+
function approvalHost(title) {
|
|
2712
|
+
const m = title.trim().match(/^(?:[A-Z]+\s+)?(?:[a-z]+:\/\/)?([^/\s?#]+)/);
|
|
2713
|
+
return (m?.[1] ?? title.trim()).toLowerCase();
|
|
2714
|
+
}
|
|
2715
|
+
var Sbx = class {
|
|
2716
|
+
// sandbox name → "allow foo.com" entries HQ added
|
|
2717
|
+
constructor(hub) {
|
|
2718
|
+
this.hub = hub;
|
|
2719
|
+
this.ownedFile = join9(projectHome(hub.slug), "sbx-owned.json");
|
|
2720
|
+
this.owned = existsSync7(this.ownedFile) && parseCliJson(readFileSync7(this.ownedFile, "utf8")) || {};
|
|
2721
|
+
this.state = { basePolicy: "unknown", proxy: null, globalKeys: [], agents: {}, checkedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
2722
|
+
}
|
|
2723
|
+
hub;
|
|
2724
|
+
state;
|
|
2725
|
+
ownedFile;
|
|
2726
|
+
owned;
|
|
2727
|
+
saveOwned() {
|
|
2728
|
+
writeFileSync7(this.ownedFile, JSON.stringify(this.owned, null, 2));
|
|
2729
|
+
}
|
|
2730
|
+
name(agentId) {
|
|
2731
|
+
return sessionName(this.hub.slug, agentId);
|
|
2732
|
+
}
|
|
2733
|
+
envFile(agentId) {
|
|
2734
|
+
return join9(projectHome(this.hub.slug), `${agentId}.env`);
|
|
2735
|
+
}
|
|
2736
|
+
publish() {
|
|
2737
|
+
this.state.checkedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2738
|
+
this.hub.snap.sbx = this.state;
|
|
2739
|
+
this.hub.emit({ type: "sbx.updated", sbx: structuredClone(this.state) });
|
|
2740
|
+
}
|
|
2741
|
+
// ------------------------------------------------------------ read back
|
|
2742
|
+
/** Network rules in effect, or null when sbx's list couldn't be read (not the same as "no rules"). */
|
|
2743
|
+
async rules() {
|
|
2744
|
+
const r = await run("sbx", ["policy", "ls", "--json", "--type", "network"], { timeoutMs: 15e3 });
|
|
2745
|
+
if (!r.ok) return null;
|
|
2746
|
+
const data = parseCliJson(r.stdout);
|
|
2747
|
+
if (!data) return null;
|
|
2748
|
+
return (data.rules ?? []).filter((x) => x.resource_type === "network" && x.status !== "inactive");
|
|
2749
|
+
}
|
|
2750
|
+
async basePolicy() {
|
|
2751
|
+
const allowed = async (target) => (await policyVerdict(target)).verdict === "allowed";
|
|
2752
|
+
if (await allowed("hq-probe.invalid")) return "allow-all";
|
|
2753
|
+
if (await allowed("registry.npmjs.org")) return "balanced";
|
|
2754
|
+
return "deny-all";
|
|
2755
|
+
}
|
|
2756
|
+
/** Re-reads everything from sbx and publishes it. */
|
|
2757
|
+
async refresh() {
|
|
2758
|
+
if (!this.hub.snap.setup.sandboxReady) {
|
|
2759
|
+
this.state.agents = Object.fromEntries(this.hub.snap.config.team.map((a) => [a.id, this.emptyAgent(a.id)]));
|
|
2760
|
+
return this.publish();
|
|
2761
|
+
}
|
|
2762
|
+
const [rules, keysOut, proxyOut, base] = await Promise.all([
|
|
2763
|
+
this.rules().then((list) => list ?? []),
|
|
2764
|
+
run("sbx", ["secret", "ls", "--json"], { timeoutMs: 15e3 }),
|
|
2765
|
+
run("sbx", ["settings", "get", "proxy.sandbox"], { timeoutMs: 1e4 }),
|
|
2766
|
+
this.basePolicy()
|
|
2767
|
+
]);
|
|
2768
|
+
let secrets = [];
|
|
2769
|
+
try {
|
|
2770
|
+
secrets = parseCliJson(keysOut.stdout)?.secrets ?? [];
|
|
2771
|
+
} catch {
|
|
2772
|
+
}
|
|
2773
|
+
this.state.basePolicy = base;
|
|
2774
|
+
this.state.proxy = proxyOut.stdout.trim() || null;
|
|
2775
|
+
this.state.globalKeys = secrets.filter((s) => s.type === "service" && s.scope === "global").map((s) => s.name);
|
|
2776
|
+
const prevErrors = this.state.agents;
|
|
2777
|
+
this.state.agents = {};
|
|
2778
|
+
for (const a of this.hub.snap.config.team) {
|
|
2779
|
+
const name = this.name(a.id);
|
|
2780
|
+
const mine = new Set(this.owned[name] ?? []);
|
|
2781
|
+
const sandboxRules = rules.filter((r) => r.applies_to === `sandbox:${name}`).map((r) => ({
|
|
2782
|
+
id: r.id,
|
|
2783
|
+
decision: r.decision,
|
|
2784
|
+
resources: r.resources,
|
|
2785
|
+
source: r.layer === "org" ? "org" : r.origin === "kit" || ["default", "provisioned"].includes(r.provenance?.created_via ?? "") ? "kit" : r.resources.every((x) => mine.has(key2(r.decision, x))) ? "hq" : "cli"
|
|
2786
|
+
}));
|
|
2787
|
+
this.state.agents[a.id] = {
|
|
2788
|
+
rules: sandboxRules,
|
|
2789
|
+
// sbx reports a sandbox scope as the bare sandbox name.
|
|
2790
|
+
keys: secrets.filter((s) => s.type === "service" && (s.scope === name || s.scope === `sandbox:${name}`)).map((s) => s.name),
|
|
2791
|
+
envFile: { path: this.envFile(a.id), exists: existsSync7(this.envFile(a.id)) },
|
|
2792
|
+
syncError: prevErrors[a.id]?.syncError ?? null,
|
|
2793
|
+
approvals: prevErrors[a.id]?.approvals ?? [],
|
|
2794
|
+
ports: prevErrors[a.id]?.ports ?? []
|
|
2795
|
+
};
|
|
2796
|
+
}
|
|
2797
|
+
this.publish();
|
|
2798
|
+
await this.refreshPorts();
|
|
2799
|
+
}
|
|
2800
|
+
emptyAgent(agentId) {
|
|
2801
|
+
return { rules: [], keys: [], envFile: { path: this.envFile(agentId), exists: existsSync7(this.envFile(agentId)) }, syncError: null, approvals: [], ports: [] };
|
|
2802
|
+
}
|
|
2803
|
+
// ------------------------------------------------------------ ports
|
|
2804
|
+
/** Published ports of one sandbox, or null if sbx couldn't list them (e.g. the sandbox is gone). */
|
|
2805
|
+
async readPorts(agentId) {
|
|
2806
|
+
const r = await run("sbx", ["ports", this.name(agentId), "--json"], { timeoutMs: 1e4 });
|
|
2807
|
+
const data = r.ok ? parseCliJson(r.stdout) : null;
|
|
2808
|
+
if (!data) return null;
|
|
2809
|
+
const rows = Array.isArray(data) ? data : data.ports ?? [];
|
|
2810
|
+
const seen = /* @__PURE__ */ new Map();
|
|
2811
|
+
for (const p of rows) if (p.sandbox_port && p.host_port) seen.set(p.sandbox_port, { sandbox: p.sandbox_port, host: p.host_port });
|
|
2812
|
+
return [...seen.values()].sort((a, b) => a.sandbox - b.sandbox);
|
|
2813
|
+
}
|
|
2814
|
+
/** Re-reads forwarded ports for running sandboxed agents. */
|
|
2815
|
+
async refreshPorts(agentId) {
|
|
2816
|
+
const ids = agentId ? [agentId] : this.hub.snap.config.team.map((a) => a.id);
|
|
2817
|
+
let changed = false;
|
|
2818
|
+
for (const id of ids) {
|
|
2819
|
+
const a = this.hub.cfg(id);
|
|
2820
|
+
const st = this.state.agents[id];
|
|
2821
|
+
if (!a || !st) continue;
|
|
2822
|
+
const running = a.mode === "sandbox" && this.hub.status(id)?.session.state === "running";
|
|
2823
|
+
const ports = running ? await this.readPorts(id) ?? [] : [];
|
|
2824
|
+
if (JSON.stringify(ports) !== JSON.stringify(st.ports ?? [])) st.ports = ports, changed = true;
|
|
2825
|
+
}
|
|
2826
|
+
if (changed) this.publish();
|
|
2827
|
+
}
|
|
2828
|
+
/**
|
|
2829
|
+
* Makes a port inside the agent's sandbox reachable at the same port on this machine (loopback
|
|
2830
|
+
* only), e.g. for a CLI sign-in whose browser callback goes to localhost:<port>.
|
|
2831
|
+
*/
|
|
2832
|
+
async forwardPort(agentId, port) {
|
|
2833
|
+
const a = this.hub.cfg(agentId);
|
|
2834
|
+
if (!a || a.mode !== "sandbox" || !Number.isInteger(port) || port < 1 || port > 65535) return;
|
|
2835
|
+
const r = await run("sbx", ["ports", this.name(agentId), "--publish", `127.0.0.1:${port}:${port}`], { timeoutMs: 3e4 });
|
|
2836
|
+
if (r.ok) this.hub.activity("config", agentId, `Forwarded port ${port} from ${a.name}'s sandbox to localhost:${port} on this machine.`);
|
|
2837
|
+
else {
|
|
2838
|
+
const why = `${r.stdout}
|
|
2839
|
+
${r.stderr}`.trim().split("\n").filter(Boolean).pop()?.replace(/^error: /i, "") ?? "";
|
|
2840
|
+
const inUse = /in use|already allocated|address already/i.test(why);
|
|
2841
|
+
this.hub.activity(
|
|
2842
|
+
"config",
|
|
2843
|
+
agentId,
|
|
2844
|
+
`Couldn't forward port ${port} from ${a.name}'s sandbox: ${inUse ? `something on this machine already uses port ${port}. Stop it and try again.` : why.slice(0, 160)}`
|
|
2845
|
+
);
|
|
2846
|
+
}
|
|
2847
|
+
await this.refreshPorts(agentId);
|
|
2848
|
+
}
|
|
2849
|
+
async unforwardPort(agentId, port) {
|
|
2850
|
+
const a = this.hub.cfg(agentId);
|
|
2851
|
+
if (!a || !Number.isInteger(port) || port < 1 || port > 65535) return;
|
|
2852
|
+
const host = this.state.agents[agentId]?.ports?.find((p) => p.sandbox === port)?.host ?? port;
|
|
2853
|
+
const r = await run("sbx", ["ports", this.name(agentId), "--unpublish", `127.0.0.1:${host}:${port}`], { timeoutMs: 3e4 });
|
|
2854
|
+
if (!r.ok) this.hub.activity("config", agentId, `Couldn't stop forwarding port ${port}: ${(r.stderr || r.stdout).trim().split("\n").pop()?.slice(0, 160)}`);
|
|
2855
|
+
await this.refreshPorts(agentId);
|
|
2856
|
+
}
|
|
2857
|
+
// ------------------------------------------------------------ approvals
|
|
2858
|
+
seenApprovals = /* @__PURE__ */ new Set();
|
|
2859
|
+
approvalTimer = null;
|
|
2860
|
+
/** sbx's queue of blocked requests. It has no JSON output, so the table is read by its header's columns. */
|
|
2861
|
+
async readApprovals() {
|
|
2862
|
+
const r = await run("sbx", ["policy", "approval", "ls"], { timeoutMs: 1e4 });
|
|
2863
|
+
if (!r.ok) return null;
|
|
2864
|
+
const lines = r.stdout.split("\n").filter((l) => l.trim());
|
|
2865
|
+
const head = lines.findIndex((l) => /^APPROVAL\s+SANDBOX\s+TITLE\s+DETAIL/.test(l));
|
|
2866
|
+
if (head === -1) return lines.every((l) => /no pending approvals/i.test(l)) ? [] : null;
|
|
2867
|
+
const at = ["APPROVAL", "SANDBOX", "TITLE", "DETAIL", "OPTIONS"].map((c) => lines[head].indexOf(c));
|
|
2868
|
+
const cell = (l, i) => l.slice(at[i], at[i + 1] > 0 ? at[i + 1] : void 0).trim();
|
|
2869
|
+
return lines.slice(head + 1).map((l) => ({ id: cell(l, 0), sandbox: cell(l, 1), title: cell(l, 2), detail: cell(l, 3) })).filter((a) => /^network:\S+$/.test(a.id));
|
|
2870
|
+
}
|
|
2871
|
+
approvalReads = Promise.resolve();
|
|
2872
|
+
approvalReadsPending = 0;
|
|
2873
|
+
/** Reads run one at a time, in order, so an older, slower read can't overwrite a newer one. */
|
|
2874
|
+
refreshApprovals() {
|
|
2875
|
+
this.approvalReadsPending++;
|
|
2876
|
+
const next = this.approvalReads.then(() => this.readAndShowApprovals());
|
|
2877
|
+
this.approvalReads = next.catch(() => {
|
|
2878
|
+
}).finally(() => this.approvalReadsPending--);
|
|
2879
|
+
return next;
|
|
2880
|
+
}
|
|
2881
|
+
/** Re-reads the approval queue and shows each HQ agent's blocked requests (new ones once in the activity feed). */
|
|
2882
|
+
async readAndShowApprovals() {
|
|
2883
|
+
if (!this.hub.snap.setup.sandboxReady) return;
|
|
2884
|
+
const all = await this.readApprovals();
|
|
2885
|
+
if (!all) return;
|
|
2886
|
+
let changed = false;
|
|
2887
|
+
const fresh = [];
|
|
2888
|
+
for (const a of this.hub.snap.config.team) {
|
|
2889
|
+
const name = this.name(a.id);
|
|
2890
|
+
const list = all.filter((x) => x.sandbox === name).map((x) => ({
|
|
2891
|
+
id: x.id,
|
|
2892
|
+
host: approvalHost(x.title),
|
|
2893
|
+
title: x.title.trim(),
|
|
2894
|
+
detail: x.detail.replace(/^Protocol: \S+ Resource type: \S+ /, ""),
|
|
2895
|
+
hostWide: approvalHost(x.title) === x.title.trim().toLowerCase(),
|
|
2896
|
+
protocol: x.detail.match(/Protocol:\s*(\S+)/i)?.[1]?.toUpperCase()
|
|
2897
|
+
}));
|
|
2898
|
+
const st = this.state.agents[a.id] ??= this.emptyAgent(a.id);
|
|
2899
|
+
if (JSON.stringify(st.approvals) !== JSON.stringify(list)) {
|
|
2900
|
+
st.approvals = list;
|
|
2901
|
+
changed = true;
|
|
2902
|
+
}
|
|
2903
|
+
for (const x of list) if (!this.seenApprovals.has(x.id)) this.seenApprovals.add(x.id), fresh.push({ agentId: a.id, host: x.host });
|
|
2904
|
+
}
|
|
2905
|
+
if (changed) this.publish();
|
|
2906
|
+
const byAgent = /* @__PURE__ */ new Map();
|
|
2907
|
+
for (const f of fresh) byAgent.set(f.agentId, [...byAgent.get(f.agentId) ?? [], f.host]);
|
|
2908
|
+
for (const [id, hosts] of byAgent)
|
|
2909
|
+
this.hub.activity("agent.needs-input", id, `${this.hub.name(id)}'s sandbox blocked ${hosts.slice(0, 3).join(", ")}${hosts.length > 3 ? ` and ${hosts.length - 3} more` : ""}. Allow or dismiss in the agent's drawer.`);
|
|
2910
|
+
}
|
|
2911
|
+
startApprovalPolling(ms = 5e3) {
|
|
2912
|
+
if (this.approvalTimer) return;
|
|
2913
|
+
const tick = () => void (this.approvalReadsPending ? Promise.resolve() : this.refreshApprovals()).catch((e) => log.warn("approvals:", e));
|
|
2914
|
+
tick();
|
|
2915
|
+
this.approvalTimer = setInterval(tick, ms);
|
|
2916
|
+
}
|
|
2917
|
+
stopApprovalPolling() {
|
|
2918
|
+
if (this.approvalTimer) clearInterval(this.approvalTimer);
|
|
2919
|
+
this.approvalTimer = null;
|
|
2920
|
+
}
|
|
2921
|
+
answering = /* @__PURE__ */ new Set();
|
|
2922
|
+
// agents with an answer in flight
|
|
2923
|
+
/**
|
|
2924
|
+
* Answers blocked requests. Allow adds an HQ-owned allow rule for exactly that host:port (shown and
|
|
2925
|
+
* removable in Settings → Sandbox) and clears every queued request for it; Dismiss clears only the one.
|
|
2926
|
+
* "all" answers every listed request the same way. One answer per agent at a time; a second click meanwhile is ignored.
|
|
2927
|
+
*/
|
|
2928
|
+
async answerApproval(agentId, approvalId, decision) {
|
|
2929
|
+
if (this.answering.has(agentId)) return;
|
|
2930
|
+
this.answering.add(agentId);
|
|
2931
|
+
try {
|
|
2932
|
+
await this.answer(agentId, approvalId, decision);
|
|
2933
|
+
} finally {
|
|
2934
|
+
this.answering.delete(agentId);
|
|
2935
|
+
}
|
|
2936
|
+
}
|
|
2937
|
+
async respondApproval(id, option) {
|
|
2938
|
+
return (await run("sbx", ["policy", "approval", "respond", id, "--option", option], { timeoutMs: 1e4 })).ok;
|
|
2939
|
+
}
|
|
2940
|
+
async answer(agentId, approvalId, decision) {
|
|
2941
|
+
const list = [...this.state.agents[agentId]?.approvals ?? []];
|
|
2942
|
+
const target = approvalId === "all" ? null : list.find((a) => a.id === approvalId);
|
|
2943
|
+
if (approvalId !== "all" && !target) return;
|
|
2944
|
+
const respond = (id, option) => this.respondApproval(id, option);
|
|
2945
|
+
if (decision === "allow" && approvalId === "all") return this.allowAll(agentId, list);
|
|
2946
|
+
if (decision === "allow" && target) {
|
|
2947
|
+
const who = this.hub.name(agentId);
|
|
2948
|
+
const tcp = !target.protocol || target.protocol === "TCP";
|
|
2949
|
+
if (/required by policy|organi[sz]ation/i.test(target.detail) || target.hostWide === false || !tcp) {
|
|
2950
|
+
if (await respond(target.id, "allow")) this.hub.activity("config", agentId, `You approved ${target.host} for ${who}'s sandbox`);
|
|
2951
|
+
else this.hub.activity("config", agentId, `Couldn't approve ${target.host}: sbx refused it.`);
|
|
2952
|
+
return this.refreshApprovals();
|
|
2953
|
+
}
|
|
2954
|
+
const agent = this.hub.cfg(agentId);
|
|
2955
|
+
if (!agent) return;
|
|
2956
|
+
const added = !agent.sandbox.allow.includes(target.host);
|
|
2957
|
+
if (added) {
|
|
2958
|
+
agent.sandbox.allow = [...agent.sandbox.allow, target.host];
|
|
2959
|
+
this.hub.configChanged();
|
|
2960
|
+
}
|
|
2961
|
+
await this.syncRules(agentId);
|
|
2962
|
+
const err = this.state.agents[agentId]?.syncError;
|
|
2963
|
+
const applied = this.state.agents[agentId]?.rules.some((r) => r.decision === "allow" && r.resources.includes(target.host));
|
|
2964
|
+
if (!applied || err?.includes(target.host)) {
|
|
2965
|
+
if (added) {
|
|
2966
|
+
agent.sandbox.allow = agent.sandbox.allow.filter((h) => h !== target.host);
|
|
2967
|
+
this.hub.configChanged();
|
|
2968
|
+
await this.syncRules(agentId);
|
|
2969
|
+
}
|
|
2970
|
+
const why = err ? err.slice(0, 160) : agent.mode !== "sandbox" ? `${who} isn't in Sandbox mode` : "sbx didn't apply the rule";
|
|
2971
|
+
this.hub.activity("config", agentId, `Couldn't allow ${target.host}: ${why}. The request is still listed.`);
|
|
2972
|
+
return;
|
|
2973
|
+
}
|
|
2974
|
+
for (const a of list.filter((x) => x.host === target.host && (!x.protocol || x.protocol === "TCP") && x.hostWide !== false)) await respond(a.id, "dismiss");
|
|
2975
|
+
this.hub.activity("config", agentId, `You allowed ${target.host} for ${who}'s sandbox`);
|
|
2976
|
+
} else {
|
|
2977
|
+
for (const a of approvalId === "all" ? list : [target]) await respond(a.id, "dismiss");
|
|
2978
|
+
}
|
|
2979
|
+
await this.refreshApprovals();
|
|
2980
|
+
}
|
|
2981
|
+
/** Allow all: one sync for every host:port rule, then sbx's own approval for the rest (same split as one Allow). */
|
|
2982
|
+
async allowAll(agentId, list) {
|
|
2983
|
+
const agent = this.hub.cfg(agentId);
|
|
2984
|
+
if (!agent || !list.length) return;
|
|
2985
|
+
const who = this.hub.name(agentId);
|
|
2986
|
+
const viaRule = (a) => (!a.protocol || a.protocol === "TCP") && a.hostWide !== false && !/required by policy|organi[sz]ation/i.test(a.detail);
|
|
2987
|
+
const failed = [];
|
|
2988
|
+
for (const a of list.filter((x) => !viaRule(x))) if (!await this.respondApproval(a.id, "allow")) failed.push(a.host);
|
|
2989
|
+
const hosts = [...new Set(list.filter(viaRule).map((a) => a.host))];
|
|
2990
|
+
const added = hosts.filter((h) => !agent.sandbox.allow.includes(h));
|
|
2991
|
+
if (added.length) {
|
|
2992
|
+
agent.sandbox.allow = [...agent.sandbox.allow, ...added];
|
|
2993
|
+
this.hub.configChanged();
|
|
2994
|
+
}
|
|
2995
|
+
if (hosts.length) await this.syncRules(agentId);
|
|
2996
|
+
const st = this.state.agents[agentId];
|
|
2997
|
+
const applied = (h) => st?.rules.some((r) => r.decision === "allow" && r.resources.includes(h)) && !st?.syncError?.includes(h);
|
|
2998
|
+
const notApplied = hosts.filter((h) => !applied(h));
|
|
2999
|
+
const undo = added.filter((h) => notApplied.includes(h));
|
|
3000
|
+
if (undo.length) {
|
|
3001
|
+
agent.sandbox.allow = agent.sandbox.allow.filter((h) => !undo.includes(h));
|
|
3002
|
+
this.hub.configChanged();
|
|
3003
|
+
await this.syncRules(agentId);
|
|
3004
|
+
}
|
|
3005
|
+
failed.push(...notApplied);
|
|
3006
|
+
for (const a of list.filter((x) => viaRule(x) && !notApplied.includes(x.host))) await this.respondApproval(a.id, "dismiss");
|
|
3007
|
+
const ok2 = new Set(list.map((a) => a.host).filter((h) => !failed.includes(h))).size;
|
|
3008
|
+
if (ok2) this.hub.activity("config", agentId, `You allowed ${ok2} site${ok2 === 1 ? "" : "s"} for ${who}'s sandbox`);
|
|
3009
|
+
if (failed.length) {
|
|
3010
|
+
const why = agent.mode !== "sandbox" ? `${who} isn't in Sandbox mode` : st?.syncError?.slice(0, 120) || "sbx didn't apply them";
|
|
3011
|
+
this.hub.activity("config", agentId, `Couldn't allow ${failed.slice(0, 3).join(", ")}${failed.length > 3 ? ` and ${failed.length - 3} more` : ""}: ${why}. They're still listed.`);
|
|
3012
|
+
}
|
|
3013
|
+
await this.refreshApprovals();
|
|
3014
|
+
}
|
|
3015
|
+
// ------------------------------------------------------------ apply
|
|
3016
|
+
/** True if sbx knows a sandbox with this name. */
|
|
3017
|
+
async exists(agentId) {
|
|
3018
|
+
const r = await run("sbx", ["ls", "--json"], { timeoutMs: 1e4 });
|
|
3019
|
+
try {
|
|
3020
|
+
return (sandboxList(r.stdout) ?? []).some((s) => s.name === this.name(agentId));
|
|
3021
|
+
} catch {
|
|
3022
|
+
return false;
|
|
3023
|
+
}
|
|
3024
|
+
}
|
|
3025
|
+
/**
|
|
3026
|
+
* Makes the sandbox's allow/deny rules match the agent's lists. Adds what's
|
|
3027
|
+
* missing; removes only rules HQ added earlier. Needs the sandbox to exist.
|
|
3028
|
+
*/
|
|
3029
|
+
async syncRules(agentId) {
|
|
3030
|
+
const agent = this.hub.cfg(agentId);
|
|
3031
|
+
if (!agent || agent.mode !== "sandbox" || !await this.exists(agentId)) return;
|
|
3032
|
+
const name = this.name(agentId);
|
|
3033
|
+
const want = /* @__PURE__ */ new Set([...agent.sandbox.allow.map((r) => key2("allow", r)), ...agent.sandbox.deny.map((r) => key2("deny", r))]);
|
|
3034
|
+
const owned = new Set(this.owned[name] ?? []);
|
|
3035
|
+
const all = await this.rules();
|
|
3036
|
+
if (!all) {
|
|
3037
|
+
log.warn(`sbx rules for ${name}: couldn't read sbx's rule list; nothing changed`);
|
|
3038
|
+
(this.state.agents[agentId] ??= this.emptyAgent(agentId)).syncError = "Couldn't read sbx's rules; nothing was changed. Try again.";
|
|
3039
|
+
this.publish();
|
|
3040
|
+
return;
|
|
3041
|
+
}
|
|
3042
|
+
const rules = all.filter((r) => r.applies_to === `sandbox:${name}`);
|
|
3043
|
+
const have = new Set(rules.flatMap((r) => r.resources.map((x) => key2(r.decision, x))));
|
|
3044
|
+
const errors = [];
|
|
3045
|
+
for (const entry of want) {
|
|
3046
|
+
if (have.has(entry)) continue;
|
|
3047
|
+
const [decision, resource] = entry.split(" ");
|
|
3048
|
+
const r = await run("sbx", ["policy", decision, "network", "--sandbox", name, resource], { timeoutMs: 15e3 });
|
|
3049
|
+
if (r.ok) owned.add(entry);
|
|
3050
|
+
else errors.push(`${decision} ${resource}: ${r.stderr.trim().split("\n")[0]}`);
|
|
3051
|
+
}
|
|
3052
|
+
for (const entry of [...owned]) {
|
|
3053
|
+
if (want.has(entry)) continue;
|
|
3054
|
+
const [decision, resource] = entry.split(" ");
|
|
3055
|
+
const rule = rules.find((r2) => r2.decision === decision && r2.resources.includes(resource));
|
|
3056
|
+
const r = rule ? await run("sbx", ["policy", "rm", "network", "--sandbox", name, "--id", rule.id, "--force"], { timeoutMs: 15e3 }) : { ok: true, stderr: "" };
|
|
3057
|
+
if (r.ok) owned.delete(entry);
|
|
3058
|
+
else errors.push(`remove ${resource}: ${r.stderr.trim().split("\n")[0]}`);
|
|
3059
|
+
}
|
|
3060
|
+
this.owned[name] = [...owned];
|
|
3061
|
+
this.saveOwned();
|
|
3062
|
+
if (errors.length) log.warn(`sbx rules for ${name}:`, errors.join("; "));
|
|
3063
|
+
await this.refresh();
|
|
3064
|
+
if (this.state.agents[agentId]) {
|
|
3065
|
+
this.state.agents[agentId].syncError = errors.length ? errors.join("; ") : null;
|
|
3066
|
+
this.publish();
|
|
3067
|
+
}
|
|
3068
|
+
}
|
|
3069
|
+
/** Stores a service key (value via stdin, never kept). agentId null = every sandbox. */
|
|
3070
|
+
async setKey(agentId, service, value) {
|
|
3071
|
+
const args = ["secret", "set", service, ...agentId ? ["--sandbox", this.name(agentId)] : [], "--force"];
|
|
3072
|
+
const r = await run("sbx", args, { timeoutMs: 2e4, input: value.trim() + "\n" });
|
|
3073
|
+
if (!r.ok) throw new Error(r.stderr.trim().split("\n")[0] || "sbx secret set failed");
|
|
3074
|
+
await this.refresh();
|
|
3075
|
+
}
|
|
3076
|
+
async removeKey(agentId, service) {
|
|
3077
|
+
const r = await run("sbx", ["secret", "rm", service, ...agentId ? ["--sandbox", this.name(agentId)] : [], "--force"], { timeoutMs: 2e4 });
|
|
3078
|
+
if (!r.ok) throw new Error(r.stderr.trim().split("\n")[0] || "sbx secret rm failed");
|
|
3079
|
+
await this.refresh();
|
|
3080
|
+
}
|
|
3081
|
+
async setProxy(url) {
|
|
3082
|
+
const r = url ? await run("sbx", ["settings", "set", "proxy.sandbox", url], { timeoutMs: 15e3 }) : await run("sbx", ["settings", "unset", "proxy.sandbox"], { timeoutMs: 15e3 });
|
|
3083
|
+
if (!r.ok) throw new Error(r.stderr.trim().split("\n")[0] || "sbx settings failed");
|
|
3084
|
+
await this.refresh();
|
|
3085
|
+
}
|
|
3086
|
+
/** Creates the agent's env file (0600) if missing. Returns its path. Never reads values. */
|
|
3087
|
+
ensureEnvFile(agentId) {
|
|
3088
|
+
const file = this.envFile(agentId);
|
|
3089
|
+
if (!existsSync7(file)) {
|
|
3090
|
+
writeFileSync7(
|
|
3091
|
+
file,
|
|
3092
|
+
`# App variables for ${this.hub.name(agentId)} (${this.hub.snap.config.project.name}).
|
|
3093
|
+
# One KEY=value per line. These values ENTER the agent's VM \u2014 use test/dev credentials.
|
|
3094
|
+
# Restart the agent after editing. Setups Works Office never reads or commits this file.
|
|
3095
|
+
`,
|
|
3096
|
+
{ mode: 384 }
|
|
3097
|
+
);
|
|
3098
|
+
}
|
|
3099
|
+
chmodSync(file, 384);
|
|
3100
|
+
return file;
|
|
3101
|
+
}
|
|
3102
|
+
};
|
|
3103
|
+
|
|
3104
|
+
// server/sbxMcp.ts
|
|
3105
|
+
import { existsSync as existsSync8, readFileSync as readFileSync8, writeFileSync as writeFileSync8 } from "node:fs";
|
|
3106
|
+
import { join as join10 } from "node:path";
|
|
3107
|
+
var MCP_NAME = /^[a-z0-9][a-z0-9_-]{0,39}$/;
|
|
3108
|
+
var firstLine = (s) => s.trim().split("\n").filter(Boolean).pop() ?? "";
|
|
3109
|
+
var SbxMcp = class {
|
|
3110
|
+
constructor(hub, runTerminal) {
|
|
3111
|
+
this.hub = hub;
|
|
3112
|
+
this.runTerminal = runTerminal;
|
|
3113
|
+
this.attachedFile = join10(projectHome(hub.slug), "mcp-attached.json");
|
|
3114
|
+
try {
|
|
3115
|
+
if (existsSync8(this.attachedFile)) this.state.attached = parseCliJson(readFileSync8(this.attachedFile, "utf8")) ?? {};
|
|
3116
|
+
} catch {
|
|
3117
|
+
}
|
|
3118
|
+
}
|
|
3119
|
+
hub;
|
|
3120
|
+
runTerminal;
|
|
3121
|
+
state = { servers: [], attached: {}, error: null, checkedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
3122
|
+
attachedFile;
|
|
3123
|
+
queue = Promise.resolve();
|
|
3124
|
+
/** One sbx mcp operation at a time, so attach/forget records stay consistent. */
|
|
3125
|
+
serial(fn) {
|
|
3126
|
+
const next = this.queue.then(fn, fn);
|
|
3127
|
+
this.queue = next.catch(() => {
|
|
3128
|
+
});
|
|
3129
|
+
return next;
|
|
3130
|
+
}
|
|
3131
|
+
publish() {
|
|
3132
|
+
this.state.checkedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3133
|
+
this.hub.snap.mcp = this.state;
|
|
3134
|
+
this.hub.emit({ type: "mcp.updated", mcp: structuredClone(this.state) });
|
|
3135
|
+
}
|
|
3136
|
+
saveAttached() {
|
|
3137
|
+
writeFileSync8(this.attachedFile, JSON.stringify(this.state.attached, null, 2));
|
|
3138
|
+
}
|
|
3139
|
+
/** Re-reads sbx's registry. */
|
|
3140
|
+
async refresh() {
|
|
3141
|
+
if (!this.hub.snap.setup.sandboxReady) {
|
|
3142
|
+
this.state.error = "Docker Sandboxes isn't ready on this machine.";
|
|
3143
|
+
return this.publish();
|
|
3144
|
+
}
|
|
3145
|
+
const r = await run("sbx", ["mcp", "ls", "--json"], { timeoutMs: 15e3 });
|
|
3146
|
+
const data = r.ok ? parseCliJson(r.stdout) : null;
|
|
3147
|
+
if (!data) {
|
|
3148
|
+
this.state.error = firstLine(r.stderr || r.stdout) || "Couldn't read sbx's MCP servers.";
|
|
3149
|
+
return this.publish();
|
|
3150
|
+
}
|
|
3151
|
+
this.state.servers = (Array.isArray(data) ? data : data.servers ?? []).map(
|
|
3152
|
+
(s) => ({ name: s.name, transport: s.transport ?? "", status: s.status ?? "unknown", nextStep: s.next_step ?? null })
|
|
3153
|
+
);
|
|
3154
|
+
this.state.error = null;
|
|
3155
|
+
this.publish();
|
|
3156
|
+
}
|
|
3157
|
+
/** Registers a server. OAuth sign-in is a separate step (authorize). An API key goes to sbx's secret store. */
|
|
3158
|
+
add(input) {
|
|
3159
|
+
return this.serial(async () => {
|
|
3160
|
+
const name = input.name.trim().toLowerCase();
|
|
3161
|
+
if (!MCP_NAME.test(name)) return this.hub.activity("config", null, `MCP server name "${input.name}" isn't valid: use letters, numbers, - or _.`);
|
|
3162
|
+
const url = input.url?.trim();
|
|
3163
|
+
const command = input.command?.trim();
|
|
3164
|
+
if (!url && !command || url?.startsWith("-") || command?.startsWith("-")) return this.hub.activity("config", null, "Give the MCP server a URL (or a command for a local server).");
|
|
3165
|
+
const args = ["mcp", "add", name, "--skip-auth"];
|
|
3166
|
+
if (url) args.push("--url", url);
|
|
3167
|
+
else args.push("--command", command, ...input.args?.length ? ["--args", input.args.join(",")] : []);
|
|
3168
|
+
const key3 = input.apiKey?.trim();
|
|
3169
|
+
if (key3 && url) args.push("--header", "Authorization: Bearer ${token}");
|
|
3170
|
+
const r = await run("sbx", args, { timeoutMs: 12e4 });
|
|
3171
|
+
if (!r.ok) {
|
|
3172
|
+
this.hub.activity("config", null, `Couldn't add MCP server ${name}: ${firstLine(r.stderr || r.stdout).slice(0, 200)}`);
|
|
3173
|
+
return this.refresh();
|
|
3174
|
+
}
|
|
3175
|
+
if (key3 && url) {
|
|
3176
|
+
const s = await run("sbx", ["secret", "set", `mcp:${name}:token`, "--force"], { timeoutMs: 2e4, input: key3 + "\n" });
|
|
3177
|
+
if (!s.ok) this.hub.activity("config", null, `Added ${name}, but couldn't store its key: ${firstLine(s.stderr).slice(0, 160)}`);
|
|
3178
|
+
}
|
|
3179
|
+
this.hub.activity("config", null, `Added MCP server ${name}`);
|
|
3180
|
+
await this.refresh();
|
|
3181
|
+
});
|
|
3182
|
+
}
|
|
3183
|
+
/** Signs in to a server (opens the browser) in a terminal you can watch. */
|
|
3184
|
+
authorize(name) {
|
|
3185
|
+
if (!MCP_NAME.test(name)) return;
|
|
3186
|
+
this.runTerminal(`setup:mcp-auth-${name}`, "sbx", ["mcp", "auth", name], () => void this.refresh());
|
|
3187
|
+
}
|
|
3188
|
+
/** Unregisters a server from sbx (every sandbox on this machine), and from every agent. */
|
|
3189
|
+
remove(name) {
|
|
3190
|
+
return this.serial(async () => {
|
|
3191
|
+
if (!MCP_NAME.test(name)) return;
|
|
3192
|
+
const r = await run("sbx", ["mcp", "rm", name, "--force"], { timeoutMs: 3e4 });
|
|
3193
|
+
if (!r.ok) {
|
|
3194
|
+
this.hub.activity("config", null, `Couldn't remove MCP server ${name}: ${firstLine(r.stderr || r.stdout).slice(0, 200)}`);
|
|
3195
|
+
return this.refresh();
|
|
3196
|
+
}
|
|
3197
|
+
for (const secret of [`mcp:${name}:token`, `mcp:${name}:token:endpoint`]) await run("sbx", ["secret", "rm", secret, "--force"], { timeoutMs: 15e3 });
|
|
3198
|
+
let changed = false;
|
|
3199
|
+
for (const a of this.hub.snap.config.team)
|
|
3200
|
+
if (a.mcp?.includes(name)) {
|
|
3201
|
+
a.mcp = a.mcp.filter((n) => n !== name);
|
|
3202
|
+
changed = true;
|
|
3203
|
+
}
|
|
3204
|
+
if (changed) this.hub.configChanged();
|
|
3205
|
+
for (const id of Object.keys(this.state.attached)) this.state.attached[id] = this.state.attached[id].filter((n) => n !== name);
|
|
3206
|
+
this.saveAttached();
|
|
3207
|
+
this.hub.activity("config", null, `Removed MCP server ${name} from Docker Sandboxes`);
|
|
3208
|
+
await this.refresh();
|
|
3209
|
+
});
|
|
3210
|
+
}
|
|
3211
|
+
/** Which servers an agent gets. New ones are attached now if its sandbox is running. */
|
|
3212
|
+
setAgent(agentId, names) {
|
|
3213
|
+
return this.serial(async () => {
|
|
3214
|
+
const agent = this.hub.cfg(agentId);
|
|
3215
|
+
if (!agent) return;
|
|
3216
|
+
const want = [...new Set(names.map((n) => n.trim().toLowerCase()).filter((n) => MCP_NAME.test(n)))];
|
|
3217
|
+
const before = agent.mcp ?? [];
|
|
3218
|
+
agent.mcp = want;
|
|
3219
|
+
this.hub.configChanged();
|
|
3220
|
+
const running = agent.mode === "sandbox" && this.hub.status(agentId)?.session.state === "running";
|
|
3221
|
+
if (running) await this.attach(agentId, want.filter((n) => !before.includes(n)));
|
|
3222
|
+
const stuck = before.filter((n) => !want.includes(n) && this.state.attached[agentId]?.includes(n));
|
|
3223
|
+
if (stuck.length)
|
|
3224
|
+
this.hub.activity(
|
|
3225
|
+
"config",
|
|
3226
|
+
agentId,
|
|
3227
|
+
`${agent.name}'s sandbox keeps ${stuck.join(", ")} until it's recreated (sbx can't detach a server). Settings \u2192 MCP \u2192 Recreate sandbox.`
|
|
3228
|
+
);
|
|
3229
|
+
this.publish();
|
|
3230
|
+
});
|
|
3231
|
+
}
|
|
3232
|
+
/** The agent's sandbox just started: attach its servers. */
|
|
3233
|
+
attachAll(agentId) {
|
|
3234
|
+
return this.serial(async () => {
|
|
3235
|
+
const agent = this.hub.cfg(agentId);
|
|
3236
|
+
if (!agent || agent.mode !== "sandbox" || !agent.mcp?.length) return;
|
|
3237
|
+
await this.attach(agentId, agent.mcp);
|
|
3238
|
+
this.publish();
|
|
3239
|
+
});
|
|
3240
|
+
}
|
|
3241
|
+
/** The agent's sandbox was removed or recreated: nothing is attached any more. */
|
|
3242
|
+
forget(agentId) {
|
|
3243
|
+
delete this.state.attached[agentId];
|
|
3244
|
+
this.saveAttached();
|
|
3245
|
+
this.publish();
|
|
3246
|
+
}
|
|
3247
|
+
async attach(agentId, names) {
|
|
3248
|
+
const sandbox = sessionName(this.hub.slug, agentId);
|
|
3249
|
+
const failed = [];
|
|
3250
|
+
for (const name of names) {
|
|
3251
|
+
const r = await run("sbx", ["mcp", "load", name, "--sandbox", sandbox], { timeoutMs: 6e4 });
|
|
3252
|
+
if (r.ok) this.state.attached[agentId] = [.../* @__PURE__ */ new Set([...this.state.attached[agentId] ?? [], name])];
|
|
3253
|
+
else failed.push(`${name}: ${firstLine(r.stderr || r.stdout).replace(/^error: /, "").slice(0, 140)}`);
|
|
3254
|
+
}
|
|
3255
|
+
this.saveAttached();
|
|
3256
|
+
const needsSignIn = names.filter((n) => this.state.servers.find((s) => s.name === n)?.status === "needs-auth");
|
|
3257
|
+
if (failed.length) this.hub.activity("config", agentId, `Couldn't attach MCP to ${this.hub.name(agentId)}: ${failed.join("; ")}`);
|
|
3258
|
+
if (needsSignIn.length) this.hub.activity("config", agentId, `${needsSignIn.join(", ")} needs sign-in before ${this.hub.name(agentId)} can use it (Settings \u2192 MCP \u2192 Sign in).`);
|
|
3259
|
+
}
|
|
3260
|
+
};
|
|
3261
|
+
|
|
3262
|
+
// server/lib/ptyFix.ts
|
|
3263
|
+
import { chmodSync as chmodSync2, existsSync as existsSync9, statSync as statSync2 } from "node:fs";
|
|
3264
|
+
import { createRequire } from "node:module";
|
|
3265
|
+
import { dirname as dirname2, join as join11 } from "node:path";
|
|
3266
|
+
function ensurePtyHelperExecutable() {
|
|
3267
|
+
if (process.platform === "win32") return;
|
|
3268
|
+
try {
|
|
3269
|
+
const require2 = createRequire(createRequire(import.meta.url).resolve("@lydell/node-pty"));
|
|
3270
|
+
const root = dirname2(dirname2(require2.resolve(`@lydell/node-pty-${process.platform}-${process.arch}`)));
|
|
3271
|
+
const helper = join11(root, "prebuilds", `${process.platform}-${process.arch}`, "spawn-helper");
|
|
3272
|
+
if (existsSync9(helper) && !(statSync2(helper).mode & 73)) chmodSync2(helper, 493);
|
|
3273
|
+
} catch {
|
|
3274
|
+
}
|
|
3275
|
+
}
|
|
3276
|
+
|
|
3277
|
+
// server/repo.ts
|
|
3278
|
+
import { existsSync as existsSync10 } from "node:fs";
|
|
3279
|
+
import { basename, join as join12 } from "node:path";
|
|
3280
|
+
async function detectRepo(start) {
|
|
3281
|
+
const top = await run("git", ["rev-parse", "--show-toplevel"], { cwd: start });
|
|
3282
|
+
if (!top.ok) throw new Error(`${start} is not inside a git repository. Run HQ from your project folder or pass --repo <path>.`);
|
|
3283
|
+
const root = top.stdout.trim();
|
|
3284
|
+
const origin = await run("git", ["remote", "get-url", "origin"], { cwd: root });
|
|
3285
|
+
const originUrl = origin.ok ? origin.stdout.trim() : null;
|
|
3286
|
+
const m = originUrl?.match(/github\.com[:/]([^/]+\/[^/]+?)(\.git)?$/);
|
|
3287
|
+
return {
|
|
3288
|
+
root,
|
|
3289
|
+
name: basename(root),
|
|
3290
|
+
originUrl,
|
|
3291
|
+
github: m ? m[1] : null,
|
|
3292
|
+
hasCodeRabbit: existsSync10(join12(root, ".coderabbit.yaml")) || existsSync10(join12(root, ".coderabbit.yml")),
|
|
3293
|
+
hasInsta: existsSync10(join12(root, ".insta"))
|
|
3294
|
+
};
|
|
3295
|
+
}
|
|
3296
|
+
|
|
3297
|
+
// server/sessions.ts
|
|
3298
|
+
import { spawn as spawnPty3 } from "@lydell/node-pty";
|
|
3299
|
+
import { existsSync as existsSync12, rmSync as rmSync3, statSync as statSync3 } from "node:fs";
|
|
3300
|
+
import { homedir as homedir2 } from "node:os";
|
|
3301
|
+
import { resolve as resolve3, sep as sep3 } from "node:path";
|
|
3302
|
+
|
|
3303
|
+
// server/adapters.ts
|
|
3304
|
+
var claude = {
|
|
3305
|
+
async writeFiles(dir2, agent, cfg, token, extras = {}) {
|
|
3306
|
+
const ownRole = await adoptRoleFile(dir2);
|
|
3307
|
+
writeClaudeFiles(dir2, agent, cfg, token, { ...extras, ownRole });
|
|
3308
|
+
},
|
|
3309
|
+
launch(agent, dir2) {
|
|
3310
|
+
const m = agent.model.trim();
|
|
3311
|
+
const model = !m || /^default$/i.test(m) ? null : /^(opus|sonnet|haiku)$/i.test(m) ? m.toLowerCase() : m;
|
|
3312
|
+
return { sbxAgent: "claude", args: ["--mcp-config", `${dir2}/${MCP_CONFIG}`, ...model ? ["--model", model] : []] };
|
|
3313
|
+
}
|
|
3314
|
+
};
|
|
3315
|
+
var CODEX_CONFIG = ".codex/config.toml";
|
|
3316
|
+
var CODEX_EVENTS = ["SessionStart", "UserPromptSubmit", "PreToolUse", "PermissionRequest", "Stop", "SessionEnd"];
|
|
3317
|
+
var t = (s) => JSON.stringify(s);
|
|
3318
|
+
function codexConfig(agent, cfg, token, dir2, extras = {}) {
|
|
3319
|
+
const url = hqUrl(agent, cfg.project.hqPort);
|
|
3320
|
+
const script = hookScriptFor(agent, dir2);
|
|
3321
|
+
const lines = [
|
|
3322
|
+
"# Written by Setups Works Office for this agent (git-excluded). Codex loads it once you trust this folder.",
|
|
3323
|
+
`developer_instructions = ${t(roleMd(agent, cfg, extras))}`,
|
|
3324
|
+
"",
|
|
3325
|
+
"[features]",
|
|
3326
|
+
"hooks = true",
|
|
3327
|
+
"",
|
|
3328
|
+
"[mcp_servers.hq]",
|
|
3329
|
+
`url = ${t(`${url}/mcp`)}`,
|
|
3330
|
+
`http_headers = { Authorization = ${t(`Bearer ${token}`)}, "X-HQ-Agent" = ${t(agent.id)} }`
|
|
3331
|
+
];
|
|
3332
|
+
for (const event of CODEX_EVENTS)
|
|
3333
|
+
lines.push("", `[[hooks.${event}]]`, `[[hooks.${event}.hooks]]`, 'type = "command"', `command = ${t(hookCommand(url, token, agent.id, event, script))}`);
|
|
3334
|
+
return lines.join("\n") + "\n";
|
|
3335
|
+
}
|
|
3336
|
+
var codex = {
|
|
3337
|
+
async writeFiles(dir2, agent, cfg, token, extras = {}) {
|
|
3338
|
+
const tracked = await run("git", ["ls-files", "--error-unmatch", CODEX_CONFIG], { cwd: dir2 });
|
|
3339
|
+
if (tracked.ok) throw new Error(`This repo commits ${CODEX_CONFIG}, so HQ can't add its Codex settings without changing a tracked file. Use Claude Code for this agent, or remove that file from the repo.`);
|
|
3340
|
+
const ownRole = await adoptRoleFile(dir2);
|
|
3341
|
+
agentWrite(dir2, CODEX_CONFIG, codexConfig(agent, cfg, token, dir2, { ...extras, ownRole }), { mode: 384 });
|
|
3342
|
+
if (hookScriptFor(agent, dir2)) writeHookScript(dir2);
|
|
3343
|
+
excludeFromGit(dir2, [CODEX_CONFIG, HOOK_SCRIPT]);
|
|
3344
|
+
},
|
|
3345
|
+
launch(agent) {
|
|
3346
|
+
const model = agent.model.trim() && !/^default$/i.test(agent.model.trim()) ? agent.model.trim() : null;
|
|
3347
|
+
return { sbxAgent: "codex", args: model ? ["-m", model] : [] };
|
|
3348
|
+
}
|
|
3349
|
+
};
|
|
3350
|
+
var adapters = { claude, codex };
|
|
3351
|
+
var localBinary = { claude: "claude", codex: "codex" };
|
|
3352
|
+
|
|
3353
|
+
// server/workspace.ts
|
|
3354
|
+
import { existsSync as existsSync11 } from "node:fs";
|
|
3355
|
+
function workspaceFor(agent, projectSlug) {
|
|
3356
|
+
return agent.workspacePath || managedWorkspace(projectSlug, agent.id);
|
|
3357
|
+
}
|
|
3358
|
+
async function ensureWorkspace(agent, projectSlug, repo) {
|
|
3359
|
+
const dir2 = workspaceFor(agent, projectSlug);
|
|
3360
|
+
if (agent.workspacePath) {
|
|
3361
|
+
if (!existsSync11(dir2)) throw new Error(`Folder not found: ${dir2}`);
|
|
3362
|
+
if (!(await run("git", ["rev-parse", "--git-dir"], { cwd: dir2 })).ok) throw new Error(`${dir2} is not a git repository.`);
|
|
3363
|
+
return dir2;
|
|
3364
|
+
}
|
|
3365
|
+
if (!existsSync11(dir2)) {
|
|
3366
|
+
const clone = await run("git", ["clone", "--quiet", repo.root, dir2], { timeoutMs: 3e5 });
|
|
3367
|
+
if (!clone.ok) throw new Error(`git clone failed: ${clone.stderr.trim()}`);
|
|
3368
|
+
}
|
|
3369
|
+
if (repo.originUrl) await run("git", ["remote", "set-url", "origin", repo.originUrl], { cwd: dir2 });
|
|
3370
|
+
return dir2;
|
|
3371
|
+
}
|
|
3372
|
+
|
|
3373
|
+
// server/sessions.ts
|
|
3374
|
+
var TERM_ENV3 = { ...process.env, TERM: "xterm-256color", COLORTERM: "truecolor" };
|
|
3375
|
+
var SHELL_ENV = { ...TERM_ENV3, FORCE_HYPERLINK: "1" };
|
|
3376
|
+
var SANDBOX_BROWSER = "/usr/local/bin/xdg-open";
|
|
3377
|
+
var BUSY = /esc to interrupt/i;
|
|
3378
|
+
var DIALOG = /Enter to select|↑\/↓ to navigate|Do you want to (proceed|make this edit|create)|Would you like to|^\s*[❯›]\s*1\.\s/im;
|
|
3379
|
+
var PROMPT_READY = /⏵⏵|\? for shortcuts|Ask Codex to do anything/;
|
|
3380
|
+
var SCREEN_QUIET_MS = 4e3;
|
|
3381
|
+
var STALE_WORKING_MS = 15e3;
|
|
3382
|
+
var SANDBOX_SHELL = `export FORCE_HYPERLINK=1; [ -x ${SANDBOX_BROWSER} ] && export BROWSER=${SANDBOX_BROWSER}; command -v bash >/dev/null && exec bash -l || exec sh -l`;
|
|
3383
|
+
var Sessions = class {
|
|
3384
|
+
constructor(hub, agentToken2) {
|
|
3385
|
+
this.hub = hub;
|
|
3386
|
+
this.agentToken = agentToken2;
|
|
3387
|
+
}
|
|
3388
|
+
hub;
|
|
3389
|
+
agentToken;
|
|
3390
|
+
backend = sessionBackend();
|
|
3391
|
+
terms = /* @__PURE__ */ new Map();
|
|
3392
|
+
sizes = /* @__PURE__ */ new Map();
|
|
3393
|
+
fixes = /* @__PURE__ */ new Map();
|
|
3394
|
+
/** "Shell" tab: a plain shell in the agent's sandbox (or folder), separate from its agent session. */
|
|
3395
|
+
shells = /* @__PURE__ */ new Map();
|
|
3396
|
+
closing = /* @__PURE__ */ new WeakSet();
|
|
3397
|
+
// closed on purpose: no "[shell closed]" line
|
|
3398
|
+
shellGen = /* @__PURE__ */ new Map();
|
|
3399
|
+
policyOk = false;
|
|
3400
|
+
/** Called whenever an agent's Claude Code session goes away (stop, restart, crash). */
|
|
3401
|
+
onSessionDown = () => {
|
|
3402
|
+
};
|
|
3403
|
+
/** Called once an agent's sandbox exists after Start (to apply its network rules). */
|
|
3404
|
+
onSandboxReady = () => {
|
|
3405
|
+
};
|
|
3406
|
+
/** Called when an agent's sandbox is removed or recreated (what was attached to it is gone). */
|
|
3407
|
+
onSandboxRemoved = () => {
|
|
3408
|
+
};
|
|
3409
|
+
/** The agent's env file, if it has one (passed with --env-file / sourced). */
|
|
3410
|
+
envFileFor = () => null;
|
|
3411
|
+
/** Called when HQ reattaches to a session that was already running (e.g. after an HQ restart). */
|
|
3412
|
+
onSessionReattached = () => {
|
|
3413
|
+
};
|
|
3414
|
+
/** When the agent's last hook arrived (from the inbox). */
|
|
3415
|
+
lastHookAt = () => 0;
|
|
3416
|
+
polling = null;
|
|
3417
|
+
/** Consecutive polls that saw a "working" agent's screen sitting idle. */
|
|
3418
|
+
idleScreens = /* @__PURE__ */ new Map();
|
|
3419
|
+
name(agentId) {
|
|
3420
|
+
return sessionName(this.hub.slug, agentId);
|
|
3421
|
+
}
|
|
3422
|
+
// ------------------------------------------------------------ lifecycle
|
|
3423
|
+
async start(id) {
|
|
3424
|
+
const hub = this.hub;
|
|
3425
|
+
const agent = hub.cfg(id);
|
|
3426
|
+
const st = hub.status(id);
|
|
3427
|
+
if (!agent || !st || st.session.state === "running" || st.session.state === "starting") return;
|
|
3428
|
+
const setup = hub.snap.setup;
|
|
3429
|
+
const fail2 = (detail) => {
|
|
3430
|
+
hub.setSession(id, { state: "failed", detail });
|
|
3431
|
+
hub.setStatus(id, { state: "idle", note: "Could not start" });
|
|
3432
|
+
hub.activity("session", id, `${agent.name} could not start: ${detail.split("\n")[0]}`);
|
|
3433
|
+
};
|
|
3434
|
+
if (!setup.checks.find((c) => c.id === "tmux" && c.status === "ok")) return fail2("tmux is not installed. See the setup checklist.");
|
|
3435
|
+
if (agent.mode === "sandbox" && !setup.sandboxReady) return fail2("Docker Sandboxes isn't ready on this machine. Finish the setup checklist, or switch this agent to Local mode.");
|
|
3436
|
+
const check = (cid) => setup.checks.find((c) => c.id === cid)?.status;
|
|
3437
|
+
if (agent.mode === "local" && agent.cli === "claude" && check("claude") !== "ok") return fail2("Claude Code isn't installed. See the setup checklist.");
|
|
3438
|
+
if (agent.mode === "local" && agent.cli === "codex" && check("codex") !== "ok") return fail2("Codex isn't installed on this machine. See the setup checklist, or run this agent in a sandbox.");
|
|
3439
|
+
if (agent.mode === "sandbox" && agent.cli === "codex" && check("openai-secret") !== "ok")
|
|
3440
|
+
return fail2("Codex needs an OpenAI key in sbx. Run `sbx secret set openai --oauth` (Setup checklist \u2192 Run in terminal).");
|
|
3441
|
+
const name = this.name(id);
|
|
3442
|
+
this.onSessionDown(id);
|
|
3443
|
+
try {
|
|
3444
|
+
hub.setSession(id, { state: "starting", detail: "Preparing folder\u2026", workspace: workspaceFor(agent, hub.slug) });
|
|
3445
|
+
const dir2 = await ensureWorkspace(agent, hub.slug, hub.repo);
|
|
3446
|
+
const adapter = adapters[agent.cli];
|
|
3447
|
+
const hasEnvFile = !!this.envFileFor(id) && (agent.mode === "sandbox" || this.backend.persistent);
|
|
3448
|
+
await adapter.writeFiles(dir2, agent, hub.snap.config, this.agentToken, { hasEnvFile });
|
|
3449
|
+
const specs = stackSkills(hub.snap.config.stack);
|
|
3450
|
+
if (specs.length || hasHqSkills(hub.slug, dir2)) {
|
|
3451
|
+
hub.setSession(id, { detail: "Installing agent skills\u2026" });
|
|
3452
|
+
const skills = await prepareSkills(hub.slug, dir2, specs);
|
|
3453
|
+
if (skills.error) hub.activity("session", id, `${agent.name}: some agent skills didn't install (${skills.error.slice(0, 120)}). Starting anyway.`);
|
|
3454
|
+
}
|
|
3455
|
+
if (!await this.backend.exists(name)) {
|
|
3456
|
+
const launch = adapter.launch(agent, dir2);
|
|
3457
|
+
const envFile = this.envFileFor(id);
|
|
3458
|
+
let spec = { file: localBinary[agent.cli], args: launch.args, envFile };
|
|
3459
|
+
if (agent.mode === "local" && envFile && !this.backend.persistent)
|
|
3460
|
+
hub.activity("session", id, `${agent.name}'s env file isn't loaded in Local mode on Windows. Use Sandbox mode for app variables.`);
|
|
3461
|
+
if (agent.mode === "sandbox") {
|
|
3462
|
+
hub.setSession(id, { detail: "Allowing the sandbox to reach HQ\u2026" });
|
|
3463
|
+
await this.ensurePolicy();
|
|
3464
|
+
hub.setSession(id, { detail: "Starting sandbox\u2026" });
|
|
3465
|
+
const { cpus: cpus2, memoryGb } = agent.sandbox.resources;
|
|
3466
|
+
const isNew = !await this.sandboxExists(name);
|
|
3467
|
+
const size = !isNew ? [] : [...cpus2 ? ["--cpus", String(cpus2)] : [], ...memoryGb ? ["--memory", `${Math.round(memoryGb * 1024)}m`] : []];
|
|
3468
|
+
const env = [...envFile ? ["--env-file", envFile] : [], "-e", `BROWSER=${SANDBOX_BROWSER}`];
|
|
3469
|
+
const args = launch.args.length ? ["--", ...launch.args] : [];
|
|
3470
|
+
spec = { file: "sbx", args: ["run", "--name", name, ...size, ...env, launch.sbxAgent, dir2, ...args], envFile: null };
|
|
3471
|
+
}
|
|
3472
|
+
await this.backend.create(name, dir2, spec, { cols: 200, rows: 50 });
|
|
3473
|
+
}
|
|
3474
|
+
this.attach(id);
|
|
3475
|
+
hub.setSession(id, { state: "running", detail: null, workspace: dir2 });
|
|
3476
|
+
hub.setStatus(id, { state: "idle", note: agent.mode === "sandbox" ? "Sandbox running" : "Running locally" });
|
|
3477
|
+
hub.activity("session", id, `${agent.name} started (${agent.cli === "codex" ? "Codex" : "Claude Code"}, ${agent.mode === "sandbox" ? "Docker Sandbox" : "Local mode"})`);
|
|
3478
|
+
if (agent.mode === "sandbox") {
|
|
3479
|
+
setTimeout(() => void this.refreshSandboxSizes(), 4e3);
|
|
3480
|
+
void this.whenSandboxExists(id).then((ok2) => ok2 && this.onSandboxReady(id));
|
|
3481
|
+
}
|
|
3482
|
+
} catch (err) {
|
|
3483
|
+
fail2(err instanceof Error ? err.message : String(err));
|
|
3484
|
+
}
|
|
3485
|
+
}
|
|
3486
|
+
async stop(id) {
|
|
3487
|
+
const hub = this.hub;
|
|
3488
|
+
const agent = hub.cfg(id);
|
|
3489
|
+
if (!agent) return;
|
|
3490
|
+
const name = this.name(id);
|
|
3491
|
+
this.onSessionDown(id);
|
|
3492
|
+
this.detach(id);
|
|
3493
|
+
this.closeShell(id);
|
|
3494
|
+
await this.backend.kill(name);
|
|
3495
|
+
if (agent.mode === "sandbox") void run("sbx", ["stop", name], { timeoutMs: 3e4 });
|
|
3496
|
+
hub.setSession(id, { state: "stopped", detail: null });
|
|
3497
|
+
hub.setStatus(id, { state: "idle", currentTaskId: null, note: "Stopped" });
|
|
3498
|
+
hub.activity("session", id, `${agent.name} stopped`);
|
|
3499
|
+
}
|
|
3500
|
+
/** What removing this agent would delete: folder state and whether a sandbox exists. */
|
|
3501
|
+
async inspectWorkspace(id) {
|
|
3502
|
+
const agent = this.hub.cfg(id);
|
|
3503
|
+
if (!agent) return null;
|
|
3504
|
+
const dir2 = workspaceFor(agent, this.hub.slug);
|
|
3505
|
+
const exists = existsSync12(dir2);
|
|
3506
|
+
let changedFiles = 0;
|
|
3507
|
+
let unpushedCommits = 0;
|
|
3508
|
+
if (exists) {
|
|
3509
|
+
const status = await run("git", ["status", "--porcelain"], { cwd: dir2 });
|
|
3510
|
+
changedFiles = status.stdout.split("\n").filter(Boolean).length;
|
|
3511
|
+
const log2 = await run("git", ["log", "--oneline", "--branches", "--not", "--remotes"], { cwd: dir2 });
|
|
3512
|
+
unpushedCommits = log2.stdout.split("\n").filter(Boolean).length;
|
|
3513
|
+
}
|
|
3514
|
+
const name = this.name(id);
|
|
3515
|
+
const ls = await run("sbx", ["ls", "--json"], { timeoutMs: 1e4 });
|
|
3516
|
+
let sandboxExists = false;
|
|
3517
|
+
try {
|
|
3518
|
+
sandboxExists = ls.ok && (sandboxList(ls.stdout) ?? []).some((s) => s.name === name);
|
|
3519
|
+
} catch {
|
|
3520
|
+
}
|
|
3521
|
+
return { agentId: id, path: dir2, managed: !agent.workspacePath, exists, changedFiles, unpushedCommits, sandboxName: name, sandboxExists };
|
|
3522
|
+
}
|
|
3523
|
+
/** Stops the agent and deletes what was asked. Only HQ-made folders inside HQ's home can be deleted. */
|
|
3524
|
+
async removeResources(id, opts) {
|
|
3525
|
+
const agent = this.hub.cfg(id);
|
|
3526
|
+
if (!agent) return [];
|
|
3527
|
+
const done = [];
|
|
3528
|
+
const name = this.name(id);
|
|
3529
|
+
this.onSessionDown(id);
|
|
3530
|
+
this.detach(id);
|
|
3531
|
+
this.closeShell(id);
|
|
3532
|
+
await this.backend.kill(name);
|
|
3533
|
+
if (opts.deleteSandbox) {
|
|
3534
|
+
const rm = await run("sbx", ["rm", "-f", name], { timeoutMs: 6e4 });
|
|
3535
|
+
if (rm.ok) done.push("sandbox"), this.onSandboxRemoved(id);
|
|
3536
|
+
else if (!/not found|no such/i.test(rm.stderr)) log.warn(`sbx rm ${name}: ${rm.stderr.trim()}`);
|
|
3537
|
+
} else if (agent.mode === "sandbox") void run("sbx", ["stop", name], { timeoutMs: 3e4 });
|
|
3538
|
+
if (opts.deleteFolder && !agent.workspacePath) {
|
|
3539
|
+
const home = resolve3(projectHome(this.hub.slug));
|
|
3540
|
+
const dir2 = resolve3(workspaceFor(agent, this.hub.slug));
|
|
3541
|
+
if (dir2.startsWith(home + sep3) && dir2 !== home && existsSync12(dir2)) {
|
|
3542
|
+
rmSync3(dir2, { recursive: true, force: true });
|
|
3543
|
+
done.push("folder");
|
|
3544
|
+
}
|
|
3545
|
+
}
|
|
3546
|
+
return done;
|
|
3547
|
+
}
|
|
3548
|
+
/** True if sbx lists a sandbox with this name (running or stopped). */
|
|
3549
|
+
async sandboxExists(name) {
|
|
3550
|
+
const r = await run("sbx", ["ls", "--json"], { timeoutMs: 1e4 });
|
|
3551
|
+
try {
|
|
3552
|
+
return (sandboxList(r.stdout) ?? []).some((s) => s.name === name);
|
|
3553
|
+
} catch {
|
|
3554
|
+
return false;
|
|
3555
|
+
}
|
|
3556
|
+
}
|
|
3557
|
+
/** Waits (up to ~90s) for sbx to list the agent's sandbox — creation can take a while the first time. */
|
|
3558
|
+
async whenSandboxExists(id) {
|
|
3559
|
+
for (let i = 0; i < 45; i++) {
|
|
3560
|
+
const r = await run("sbx", ["ls", "--json"], { timeoutMs: 1e4 });
|
|
3561
|
+
try {
|
|
3562
|
+
if ((sandboxList(r.stdout) ?? []).some((s) => s.name === this.name(id))) return true;
|
|
3563
|
+
} catch {
|
|
3564
|
+
}
|
|
3565
|
+
await new Promise((res) => setTimeout(res, 2e3));
|
|
3566
|
+
}
|
|
3567
|
+
return false;
|
|
3568
|
+
}
|
|
3569
|
+
/** Removes the agent's sandbox and starts a fresh one (applies new CPU/memory). The folder is kept. */
|
|
3570
|
+
async recreate(id) {
|
|
3571
|
+
const agent = this.hub.cfg(id);
|
|
3572
|
+
if (!agent || agent.mode !== "sandbox") return;
|
|
3573
|
+
const name = this.name(id);
|
|
3574
|
+
if (this.hub.status(id)?.session.state === "running") await this.stop(id);
|
|
3575
|
+
this.hub.setSession(id, { state: "starting", detail: "Removing the old sandbox\u2026", sandbox: null });
|
|
3576
|
+
const rm = await run("sbx", ["rm", "-f", name], { timeoutMs: 6e4 });
|
|
3577
|
+
if (!rm.ok && !/not found|no such/i.test(rm.stderr)) {
|
|
3578
|
+
this.hub.setSession(id, { state: "failed", detail: `sbx rm failed: ${rm.stderr.trim()}` });
|
|
3579
|
+
return;
|
|
3580
|
+
}
|
|
3581
|
+
this.onSandboxRemoved(id);
|
|
3582
|
+
this.hub.setSession(id, { state: "stopped", detail: null });
|
|
3583
|
+
this.hub.activity("session", id, `Recreating ${agent.name}'s sandbox with new resources`);
|
|
3584
|
+
await this.start(id);
|
|
3585
|
+
}
|
|
3586
|
+
/** Reads each HQ sandbox's real CPU/memory from `sbx ls --json`. */
|
|
3587
|
+
async refreshSandboxSizes() {
|
|
3588
|
+
const r = await run("sbx", ["ls", "--json"], { timeoutMs: 1e4 });
|
|
3589
|
+
if (!r.ok) return;
|
|
3590
|
+
let list = [];
|
|
3591
|
+
try {
|
|
3592
|
+
list = sandboxList(r.stdout) ?? [];
|
|
3593
|
+
} catch {
|
|
3594
|
+
return;
|
|
3595
|
+
}
|
|
3596
|
+
for (const agent of this.hub.snap.config.team) {
|
|
3597
|
+
const found = list.find((s) => s.name === this.name(agent.id));
|
|
3598
|
+
const m = this.hub.snap.setup.machine;
|
|
3599
|
+
const size = !found ? null : found.cpus && found.memory_mib ? { cpus: found.cpus, memoryGb: Math.round(found.memory_mib / 1024 * 10) / 10 } : { cpus: m.cpus, memoryGb: Math.min(32, Math.max(0.5, m.memoryGb / 2)) };
|
|
3600
|
+
const cur = this.hub.status(agent.id)?.session.sandbox;
|
|
3601
|
+
if (JSON.stringify(cur ?? null) !== JSON.stringify(size)) this.hub.setSession(agent.id, { sandbox: size });
|
|
3602
|
+
}
|
|
3603
|
+
}
|
|
3604
|
+
/**
|
|
3605
|
+
* Lets sandboxes reach HQ's port (checked once per run, added only if missing).
|
|
3606
|
+
* The sandbox proxy evaluates host.docker.internal as localhost, so both are allowed.
|
|
3607
|
+
*/
|
|
3608
|
+
async ensurePolicy() {
|
|
3609
|
+
if (this.policyOk) return;
|
|
3610
|
+
const port = this.hub.snap.config.project.hqPort;
|
|
3611
|
+
for (const target of [`localhost:${port}`, `host.docker.internal:${port}`]) {
|
|
3612
|
+
if ((await policyVerdict(target)).verdict === "allowed") continue;
|
|
3613
|
+
const add = await run("sbx", ["policy", "allow", "network", target], { timeoutMs: 1e4 });
|
|
3614
|
+
if (!add.ok && POLICY_NOT_INITIALIZED.test(add.stderr + add.stdout)) {
|
|
3615
|
+
void this.recheckSetup();
|
|
3616
|
+
throw new Error("Docker Sandboxes needs a base network policy first. Open the setup checklist \u2192 \u201CSandbox network policy\u201D, or run: sbx policy init balanced");
|
|
3617
|
+
}
|
|
3618
|
+
if (!add.ok) throw new Error(`Could not allow ${target}: ${add.stderr.trim()}`);
|
|
3619
|
+
log.info(`added sandbox network rule: allow ${target}`);
|
|
3620
|
+
}
|
|
3621
|
+
this.policyOk = true;
|
|
3622
|
+
}
|
|
3623
|
+
// ------------------------------------------------------------ terminals
|
|
3624
|
+
/** One terminal view per agent; output goes to every browser. */
|
|
3625
|
+
attach(id) {
|
|
3626
|
+
if (this.terms.has(id)) return;
|
|
3627
|
+
const size = this.sizes.get(id) ?? { cols: 120, rows: 36 };
|
|
3628
|
+
const term = this.backend.attach(
|
|
3629
|
+
this.name(id),
|
|
3630
|
+
size,
|
|
3631
|
+
(d) => this.hub.terminal(id, d),
|
|
3632
|
+
() => {
|
|
3633
|
+
if (this.terms.get(id) === term) this.terms.delete(id);
|
|
3634
|
+
}
|
|
3635
|
+
);
|
|
3636
|
+
this.terms.set(id, term);
|
|
3637
|
+
}
|
|
3638
|
+
detach(id) {
|
|
3639
|
+
this.terms.get(id)?.close();
|
|
3640
|
+
this.terms.delete(id);
|
|
3641
|
+
}
|
|
3642
|
+
/** Types a line into an agent's prompt and submits it. */
|
|
3643
|
+
async typeInto(agentId, text) {
|
|
3644
|
+
if (!await this.backend.type(this.name(agentId), text)) return false;
|
|
3645
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
3646
|
+
return this.backend.enter(this.name(agentId));
|
|
3647
|
+
}
|
|
3648
|
+
async pressEnter(agentId) {
|
|
3649
|
+
return this.backend.enter(this.name(agentId));
|
|
3650
|
+
}
|
|
3651
|
+
input(id, data) {
|
|
3652
|
+
if (id.startsWith("setup:")) return this.fixes.get(id)?.write(data);
|
|
3653
|
+
if (id.startsWith("shell:")) return this.shells.get(id)?.write(data);
|
|
3654
|
+
this.terms.get(id)?.write(data);
|
|
3655
|
+
}
|
|
3656
|
+
resize(id, cols, rows) {
|
|
3657
|
+
const c = Math.max(20, Math.min(400, Math.floor(cols)));
|
|
3658
|
+
const r = Math.max(5, Math.min(200, Math.floor(rows)));
|
|
3659
|
+
const same = this.sizes.get(id)?.cols === c && this.sizes.get(id)?.rows === r;
|
|
3660
|
+
this.sizes.set(id, { cols: c, rows: r });
|
|
3661
|
+
const current = () => id.startsWith("setup:") ? this.fixes.get(id) : id.startsWith("shell:") ? this.shells.get(id) : this.terms.get(id);
|
|
3662
|
+
const pty = current();
|
|
3663
|
+
if (!pty) return;
|
|
3664
|
+
const safeResize = (cs, rs) => {
|
|
3665
|
+
if (current() !== pty) return;
|
|
3666
|
+
try {
|
|
3667
|
+
pty.resize(cs, rs);
|
|
3668
|
+
} catch {
|
|
3669
|
+
}
|
|
3670
|
+
};
|
|
3671
|
+
if (same) {
|
|
3672
|
+
safeResize(c, Math.max(5, r - 1));
|
|
3673
|
+
setTimeout(() => safeResize(c, r), 60);
|
|
3674
|
+
} else safeResize(c, r);
|
|
3675
|
+
}
|
|
3676
|
+
async openWorkspace(id) {
|
|
3677
|
+
const st = this.hub.status(id);
|
|
3678
|
+
const agent = this.hub.cfg(id);
|
|
3679
|
+
const dir2 = st?.session.workspace ?? (agent ? workspaceFor(agent, this.hub.slug) : null);
|
|
3680
|
+
if (!dir2) return;
|
|
3681
|
+
const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
|
|
3682
|
+
await run(opener, [dir2], { timeoutMs: 5e3 });
|
|
3683
|
+
}
|
|
3684
|
+
// ------------------------------------------------------------ health
|
|
3685
|
+
/** Keeps session state honest: picks up sessions from before a restart, notices crashes. */
|
|
3686
|
+
startPolling() {
|
|
3687
|
+
const tick = async () => {
|
|
3688
|
+
if (this.hub.snap.setup.checks.find((c) => c.id === "tmux")?.status !== "ok") return;
|
|
3689
|
+
for (const agent of this.hub.snap.config.team) {
|
|
3690
|
+
const st = this.hub.status(agent.id);
|
|
3691
|
+
if (!st || st.session.state === "starting") continue;
|
|
3692
|
+
const name = this.name(agent.id);
|
|
3693
|
+
const alive = await this.backend.exists(name);
|
|
3694
|
+
if (alive) {
|
|
3695
|
+
const pane = await this.backend.exitInfo(name);
|
|
3696
|
+
if (pane?.dead) {
|
|
3697
|
+
const detail = await this.backend.screenText(name, 6) || `Exited with code ${pane.exitStatus}`;
|
|
3698
|
+
await this.backend.kill(name);
|
|
3699
|
+
this.detach(agent.id);
|
|
3700
|
+
this.closeShell(agent.id);
|
|
3701
|
+
this.onSessionDown(agent.id);
|
|
3702
|
+
this.hub.setSession(agent.id, { state: "failed", detail });
|
|
3703
|
+
this.hub.setStatus(agent.id, { state: "idle", note: "Session ended" });
|
|
3704
|
+
this.hub.activity("session", agent.id, `${agent.name}'s session ended`);
|
|
3705
|
+
continue;
|
|
3706
|
+
}
|
|
3707
|
+
this.attach(agent.id);
|
|
3708
|
+
if (st.session.state !== "running") {
|
|
3709
|
+
this.hub.setSession(agent.id, { state: "running", detail: null, workspace: workspaceFor(agent, this.hub.slug) });
|
|
3710
|
+
this.hub.setStatus(agent.id, { note: "Reattached" });
|
|
3711
|
+
this.onSessionReattached(agent.id);
|
|
3712
|
+
}
|
|
3713
|
+
if (agent.cli === "codex") await this.watchCodexScreen(agent.id, name);
|
|
3714
|
+
await this.reconcileFromScreen(agent.id, agent.cli, name);
|
|
3715
|
+
} else if (st.session.state === "running") {
|
|
3716
|
+
this.detach(agent.id);
|
|
3717
|
+
this.closeShell(agent.id);
|
|
3718
|
+
this.onSessionDown(agent.id);
|
|
3719
|
+
this.hub.setSession(agent.id, { state: "stopped", detail: null });
|
|
3720
|
+
this.hub.setStatus(agent.id, { state: "idle", note: "Stopped" });
|
|
3721
|
+
}
|
|
3722
|
+
}
|
|
3723
|
+
};
|
|
3724
|
+
void tick();
|
|
3725
|
+
let n = 0;
|
|
3726
|
+
this.polling = setInterval(() => {
|
|
3727
|
+
void tick().catch((e) => log.warn("poll:", e));
|
|
3728
|
+
if (n++ % 10 === 0 && this.hub.snap.setup.sandboxReady) void this.refreshSandboxSizes();
|
|
3729
|
+
}, 3e3);
|
|
3730
|
+
}
|
|
3731
|
+
/**
|
|
3732
|
+
* Codex fires SessionStart only on the first prompt, so HQ reads its screen:
|
|
3733
|
+
* the trust prompts mean "needs you"; the empty prompt means HQ can type.
|
|
3734
|
+
*/
|
|
3735
|
+
async watchCodexScreen(agentId, name) {
|
|
3736
|
+
const st = this.hub.status(agentId);
|
|
3737
|
+
if (!st) return;
|
|
3738
|
+
const screen = await this.backend.screenText(name, 14);
|
|
3739
|
+
const TRUST_NOTE = "Asking: approve Codex's folder and hook prompts in the terminal (choose Yes, then Trust all)";
|
|
3740
|
+
if (/Do you trust the contents of this directory|Hooks need review/i.test(screen)) {
|
|
3741
|
+
if (st.note !== TRUST_NOTE) {
|
|
3742
|
+
this.hub.setStatus(agentId, { state: "needs-input", note: TRUST_NOTE });
|
|
3743
|
+
this.hub.activity("agent.needs-input", agentId, `${this.hub.name(agentId)} needs you: approve Codex's trust prompts`);
|
|
3744
|
+
}
|
|
3745
|
+
return;
|
|
3746
|
+
}
|
|
3747
|
+
if (/Ask Codex to do anything/i.test(screen)) {
|
|
3748
|
+
this.onSessionReattached(agentId);
|
|
3749
|
+
if (st.note === TRUST_NOTE || st.note === "Sandbox running" || st.note === "Running locally" || st.note === "Reattached")
|
|
3750
|
+
this.hub.setStatus(agentId, { state: "idle", note: "Ready" });
|
|
3751
|
+
}
|
|
3752
|
+
}
|
|
3753
|
+
/**
|
|
3754
|
+
* Hooks are the main signal, but one can be missed or arrive late (a hook
|
|
3755
|
+
* that timed out, an answer with no tool call after it). When hooks have been
|
|
3756
|
+
* quiet for a while, the screen says what the agent is really doing, and a
|
|
3757
|
+
* wrong state would otherwise hold back queued messages.
|
|
3758
|
+
*/
|
|
3759
|
+
async reconcileFromScreen(agentId, cli, name) {
|
|
3760
|
+
const st = this.hub.status(agentId);
|
|
3761
|
+
if (!st || st.session.state !== "running") return;
|
|
3762
|
+
const quietMs = Date.now() - this.lastHookAt(agentId);
|
|
3763
|
+
if (quietMs < SCREEN_QUIET_MS) return;
|
|
3764
|
+
const screen = await this.backend.screenText(name, 10);
|
|
3765
|
+
if (!screen) return;
|
|
3766
|
+
const busy = BUSY.test(screen);
|
|
3767
|
+
const dialog = DIALOG.test(screen);
|
|
3768
|
+
const set = (state, note) => {
|
|
3769
|
+
if (process.env.HQ_DEBUG) log.info(`screen ${agentId}: ${st.state} \u2192 ${state} (${note})`);
|
|
3770
|
+
this.hub.setStatus(agentId, { state, note });
|
|
3771
|
+
};
|
|
3772
|
+
if (st.state === "needs-input") {
|
|
3773
|
+
if (dialog || st.note?.includes("Codex's folder")) return;
|
|
3774
|
+
this.idleScreens.delete(agentId);
|
|
3775
|
+
if (busy) return set("working", "Continuing after your answer");
|
|
3776
|
+
if (cli === "claude" && PROMPT_READY.test(screen)) set("idle", "Waiting for you");
|
|
3777
|
+
return;
|
|
3778
|
+
}
|
|
3779
|
+
if (dialog) return set("needs-input", "Asking: answer the question in the terminal");
|
|
3780
|
+
if (st.state === "idle" && busy) return set("working", "Working");
|
|
3781
|
+
if (st.state === "working" && !busy && cli === "claude") {
|
|
3782
|
+
const n = (this.idleScreens.get(agentId) ?? 0) + 1;
|
|
3783
|
+
this.idleScreens.set(agentId, n);
|
|
3784
|
+
if (n >= 2 && quietMs >= STALE_WORKING_MS) {
|
|
3785
|
+
this.idleScreens.delete(agentId);
|
|
3786
|
+
set("idle", "Finished \u2014 ready for the next instruction");
|
|
3787
|
+
}
|
|
3788
|
+
return;
|
|
3789
|
+
}
|
|
3790
|
+
this.idleScreens.delete(agentId);
|
|
3791
|
+
if (st.state === "idle" && !busy && PROMPT_READY.test(screen)) this.onSessionReattached(agentId);
|
|
3792
|
+
}
|
|
3793
|
+
// ------------------------------------------------------------ setup helper
|
|
3794
|
+
async recheckSetup() {
|
|
3795
|
+
this.hub.setSetup(await runSetupChecks(this.hub.repo, this.hub.snap.config.team));
|
|
3796
|
+
}
|
|
3797
|
+
/** Runs a program in a terminal the browser can watch and type into (id must start with "setup:"). */
|
|
3798
|
+
runTerminal(id, file, args, onExit = () => {
|
|
3799
|
+
}) {
|
|
3800
|
+
if (!id.startsWith("setup:") || this.fixes.has(id)) return;
|
|
3801
|
+
const size = this.sizes.get(id) ?? { cols: 100, rows: 16 };
|
|
3802
|
+
const pty = spawnPty3(file, args, { name: "xterm-256color", cols: size.cols, rows: size.rows, cwd: this.hub.repo.root, env: TERM_ENV3 });
|
|
3803
|
+
this.fixes.set(id, pty);
|
|
3804
|
+
this.hub.terminal(id, `\x1B[2m$ ${[file, ...args].join(" ")}\x1B[0m\r
|
|
3805
|
+
`);
|
|
3806
|
+
pty.onData((d) => this.hub.terminal(id, d));
|
|
3807
|
+
pty.onExit(({ exitCode }) => {
|
|
3808
|
+
this.fixes.delete(id);
|
|
3809
|
+
this.hub.terminal(id, `\r
|
|
3810
|
+
\x1B[${exitCode === 0 ? "32m\u2713 Done" : `31m\u2717 Exited with code ${exitCode}`}\x1B[0m\r
|
|
3811
|
+
`);
|
|
3812
|
+
onExit(exitCode);
|
|
3813
|
+
});
|
|
3814
|
+
}
|
|
3815
|
+
/** Runs a check's fix command in a visible terminal. Only commands HQ itself suggested. */
|
|
3816
|
+
runFix(checkId) {
|
|
3817
|
+
const check = this.hub.snap.setup.checks.find((c) => c.id === checkId);
|
|
3818
|
+
if (!check?.fix?.runnable || !check.fix.command) return;
|
|
3819
|
+
const id = `setup:${checkId}`;
|
|
3820
|
+
if (this.fixes.has(id)) return;
|
|
3821
|
+
const shell = process.platform === "win32" ? "powershell.exe" : process.env.SHELL || "/bin/sh";
|
|
3822
|
+
const args = process.platform === "win32" ? ["-NoProfile", "-Command", check.fix.command] : ["-lc", check.fix.command];
|
|
3823
|
+
const size = this.sizes.get(id) ?? { cols: 100, rows: 16 };
|
|
3824
|
+
const pty = spawnPty3(shell, args, { name: "xterm-256color", cols: size.cols, rows: size.rows, cwd: this.hub.repo.root, env: TERM_ENV3 });
|
|
3825
|
+
this.fixes.set(id, pty);
|
|
3826
|
+
this.hub.terminal(id, `\x1B[2m$ ${check.fix.command}\x1B[0m\r
|
|
3827
|
+
`);
|
|
3828
|
+
pty.onData((d) => this.hub.terminal(id, d));
|
|
3829
|
+
pty.onExit(({ exitCode }) => {
|
|
3830
|
+
this.fixes.delete(id);
|
|
3831
|
+
this.hub.terminal(id, `\r
|
|
3832
|
+
\x1B[${exitCode === 0 ? "32m\u2713 Done" : `31m\u2717 Exited with code ${exitCode}`}\x1B[0m\r
|
|
3833
|
+
`);
|
|
3834
|
+
void this.recheckSetup();
|
|
3835
|
+
});
|
|
3836
|
+
}
|
|
3837
|
+
// ------------------------------------------------------------ shell tab
|
|
3838
|
+
/**
|
|
3839
|
+
* Opens a shell for the "Shell" tab (terminal id `shell:<agentId>`): `sbx exec -it <sandbox> bash`
|
|
3840
|
+
* for a sandboxed agent, your own shell in its folder for Local mode. It runs beside the agent's
|
|
3841
|
+
* session, so the agent keeps working; it closes when you close it or HQ stops.
|
|
3842
|
+
*/
|
|
3843
|
+
async openShell(agentId) {
|
|
3844
|
+
const id = `shell:${agentId}`;
|
|
3845
|
+
const agent = this.hub.cfg(agentId);
|
|
3846
|
+
const st = this.hub.status(agentId);
|
|
3847
|
+
if (!agent || this.shells.has(id)) return;
|
|
3848
|
+
if (st?.session.state !== "running") return this.hub.terminal(id, "\x1B[2mStart the agent first; the shell opens inside its sandbox.\x1B[0m\r\n");
|
|
3849
|
+
const gen = (this.shellGen.get(id) ?? 0) + 1;
|
|
3850
|
+
this.shellGen.set(id, gen);
|
|
3851
|
+
const dir2 = st.session.workspace ?? workspaceFor(agent, this.hub.slug);
|
|
3852
|
+
const win = process.platform === "win32";
|
|
3853
|
+
let pty;
|
|
3854
|
+
let banner;
|
|
3855
|
+
try {
|
|
3856
|
+
let file;
|
|
3857
|
+
let args;
|
|
3858
|
+
if (agent.mode === "sandbox") {
|
|
3859
|
+
const name = this.name(agentId);
|
|
3860
|
+
const env = this.envFileFor(agentId);
|
|
3861
|
+
({ file, args } = await resolveCommand("sbx", ["exec", "-it", ...env ? ["--env-file", env] : [], ...win ? [] : ["-w", dir2], name, "sh", "-c", SANDBOX_SHELL]));
|
|
3862
|
+
banner = `sbx exec -it ${name} bash`;
|
|
3863
|
+
} else {
|
|
3864
|
+
const sh = process.env.SHELL || "/bin/sh";
|
|
3865
|
+
const env = win ? null : this.envFileFor(agentId);
|
|
3866
|
+
file = win ? "powershell.exe" : env ? "/bin/sh" : sh;
|
|
3867
|
+
args = win ? ["-NoLogo"] : env ? ["-c", `${loadEnvFile('"$1"')}; exec "$2" -l`, "sh", env, sh] : ["-l"];
|
|
3868
|
+
banner = `${sh} in ${dir2}`;
|
|
3869
|
+
}
|
|
3870
|
+
if (this.shellGen.get(id) !== gen || this.shells.has(id) || this.hub.status(agentId)?.session.state !== "running") return;
|
|
3871
|
+
const size = this.sizes.get(id) ?? { cols: 100, rows: 30 };
|
|
3872
|
+
pty = spawnPty3(file, args, { name: "xterm-256color", cols: size.cols, rows: size.rows, cwd: dir2, env: SHELL_ENV });
|
|
3873
|
+
} catch (err) {
|
|
3874
|
+
return this.hub.terminal(id, `\x1B[31mCouldn't open a shell: ${err instanceof Error ? err.message : String(err)}\x1B[0m\r
|
|
3875
|
+
`);
|
|
3876
|
+
}
|
|
3877
|
+
this.shells.set(id, pty);
|
|
3878
|
+
this.hub.terminal(id, `\x1B[2m$ ${banner}\x1B[0m\r
|
|
3879
|
+
`);
|
|
3880
|
+
const startedAt = Date.now();
|
|
3881
|
+
pty.onData((d) => this.hub.terminal(id, d));
|
|
3882
|
+
pty.onExit(({ exitCode }) => {
|
|
3883
|
+
if (this.shells.get(id) === pty) this.shells.delete(id);
|
|
3884
|
+
if (this.closing.has(pty)) return;
|
|
3885
|
+
if (exitCode && Date.now() - startedAt < 2500)
|
|
3886
|
+
return this.hub.terminal(id, `\r
|
|
3887
|
+
\x1B[31m[couldn't start the shell (exit ${exitCode}). ${agent.mode === "sandbox" ? "Check that sbx is installed and you're signed in (sbx login)." : "Check your shell."}]\x1B[0m\r
|
|
3888
|
+
`);
|
|
3889
|
+
this.hub.terminal(id, `\r
|
|
3890
|
+
\x1B[2m[shell closed${exitCode ? ` (exit ${exitCode})` : ""}. Press "New shell" to open another.]\x1B[0m\r
|
|
3891
|
+
`);
|
|
3892
|
+
});
|
|
3893
|
+
}
|
|
3894
|
+
closeShell(agentId) {
|
|
3895
|
+
const id = `shell:${agentId}`;
|
|
3896
|
+
this.shellGen.set(id, (this.shellGen.get(id) ?? 0) + 1);
|
|
3897
|
+
const pty = this.shells.get(id);
|
|
3898
|
+
if (!pty) return;
|
|
3899
|
+
this.closing.add(pty);
|
|
3900
|
+
pty.kill();
|
|
3901
|
+
this.shells.delete(id);
|
|
3902
|
+
}
|
|
3903
|
+
/**
|
|
3904
|
+
* The Terminal panel: your own shell on this machine (not in a sandbox), in the folder you pick.
|
|
3905
|
+
* Reopening with the same folder keeps the running shell; `fresh` or a new folder starts a new one.
|
|
3906
|
+
*/
|
|
3907
|
+
openHostShell(dir2, fresh) {
|
|
3908
|
+
const id = HOST_TERMINAL;
|
|
3909
|
+
const want = this.hostDir(dir2);
|
|
3910
|
+
if (!want) return this.hub.terminal(id, `\x1B[31mNo such folder: ${dir2}\x1B[0m\r
|
|
3911
|
+
`);
|
|
3912
|
+
if (this.shells.has(id) && !fresh && want === this.hostShellDir) return;
|
|
3913
|
+
if (this.shells.has(id)) this.hub.terminal(id, "\x1Bc");
|
|
3914
|
+
this.closeHostShell();
|
|
3915
|
+
const win = process.platform === "win32";
|
|
3916
|
+
const sh = win ? "powershell.exe" : process.env.SHELL || "/bin/sh";
|
|
3917
|
+
const size = this.sizes.get(id) ?? { cols: 100, rows: 30 };
|
|
3918
|
+
let pty;
|
|
3919
|
+
try {
|
|
3920
|
+
pty = spawnPty3(sh, win ? ["-NoLogo"] : ["-l"], { name: "xterm-256color", cols: size.cols, rows: size.rows, cwd: want, env: SHELL_ENV });
|
|
3921
|
+
} catch (err) {
|
|
3922
|
+
return this.hub.terminal(id, `\x1B[31mCouldn't open a shell: ${err instanceof Error ? err.message : String(err)}\x1B[0m\r
|
|
3923
|
+
`);
|
|
3924
|
+
}
|
|
3925
|
+
this.shells.set(id, pty);
|
|
3926
|
+
this.hostShellDir = want;
|
|
3927
|
+
this.hub.terminal(id, `\x1B[2m$ ${sh} in ${want}\x1B[0m\r
|
|
3928
|
+
`);
|
|
3929
|
+
pty.onData((d) => this.hub.terminal(id, d));
|
|
3930
|
+
pty.onExit(({ exitCode }) => {
|
|
3931
|
+
if (this.shells.get(id) === pty) this.shells.delete(id);
|
|
3932
|
+
if (this.closing.has(pty)) return;
|
|
3933
|
+
this.hub.terminal(id, `\r
|
|
3934
|
+
\x1B[2m[shell closed${exitCode ? ` (exit ${exitCode})` : ""}. Press "New shell" to open another.]\x1B[0m\r
|
|
3935
|
+
`);
|
|
3936
|
+
});
|
|
3937
|
+
}
|
|
3938
|
+
closeHostShell() {
|
|
3939
|
+
const pty = this.shells.get(HOST_TERMINAL);
|
|
3940
|
+
this.hostShellDir = null;
|
|
3941
|
+
if (!pty) return;
|
|
3942
|
+
this.closing.add(pty);
|
|
3943
|
+
pty.kill();
|
|
3944
|
+
this.shells.delete(HOST_TERMINAL);
|
|
3945
|
+
}
|
|
3946
|
+
hostShellDir = null;
|
|
3947
|
+
/** An existing folder from what you typed ("~" works); the repo when empty. */
|
|
3948
|
+
hostDir(dir2) {
|
|
3949
|
+
const raw = dir2.trim();
|
|
3950
|
+
if (!raw) return this.hub.repo.root;
|
|
3951
|
+
const full = resolve3(this.hub.repo.root, raw === "~" || raw.startsWith("~/") ? homedir2() + raw.slice(1) : raw);
|
|
3952
|
+
try {
|
|
3953
|
+
return statSync3(full).isDirectory() ? full : null;
|
|
3954
|
+
} catch {
|
|
3955
|
+
return null;
|
|
3956
|
+
}
|
|
3957
|
+
}
|
|
3958
|
+
shutdown() {
|
|
3959
|
+
for (const p of this.shells.values()) p.kill();
|
|
3960
|
+
if (this.polling) clearInterval(this.polling);
|
|
3961
|
+
for (const t2 of this.terms.values()) t2.close();
|
|
3962
|
+
this.backend.shutdown();
|
|
3963
|
+
for (const p of this.fixes.values()) p.kill();
|
|
3964
|
+
}
|
|
3965
|
+
};
|
|
3966
|
+
|
|
3967
|
+
// server/main.ts
|
|
3968
|
+
function arg(name) {
|
|
3969
|
+
const i = process.argv.indexOf(`--${name}`);
|
|
3970
|
+
return i > -1 ? process.argv[i + 1] : void 0;
|
|
3971
|
+
}
|
|
3972
|
+
var flag = (name) => process.argv.includes(`--${name}`);
|
|
3973
|
+
async function main() {
|
|
3974
|
+
ensurePtyHelperExecutable();
|
|
3975
|
+
const dev = flag("dev");
|
|
3976
|
+
const port = Number(arg("port") ?? process.env.HQ_PORT ?? 7420);
|
|
3977
|
+
const repo = await detectRepo(resolve4(arg("repo") ?? process.env.HQ_REPO ?? process.cwd()));
|
|
3978
|
+
const { config, isNew } = loadConfig(repo, port);
|
|
3979
|
+
config.project.hqPort = port;
|
|
3980
|
+
const slug2 = config.project.id;
|
|
3981
|
+
const token = agentToken(slug2);
|
|
3982
|
+
const uiToken = browserToken(slug2, dev);
|
|
3983
|
+
log.info(`repo ${repo.root}${repo.github ? ` (github.com/${repo.github})` : ""}`);
|
|
3984
|
+
log.info("checking setup\u2026");
|
|
3985
|
+
const setup = await runSetupChecks(repo, config.team);
|
|
3986
|
+
for (const c of setup.checks) log.info(` ${c.status === "ok" ? "\u2713" : c.status === "warning" ? "!" : "\u2717"} ${c.label}: ${c.detail}`);
|
|
3987
|
+
const hub = new Hub(repo, slug2, config, loadPlan(repo.root), setup);
|
|
3988
|
+
hub.snap.welcome = isNew;
|
|
3989
|
+
const sessions = new Sessions(hub, token);
|
|
3990
|
+
const inbox = new Inbox(hub, { type: (id, text) => sessions.typeInto(id, text), enter: (id) => sessions.pressEnter(id) });
|
|
3991
|
+
sessions.onSessionDown = (id) => inbox.sessionDown(id);
|
|
3992
|
+
sessions.onSessionReattached = (id) => inbox.sessionReattached(id);
|
|
3993
|
+
sessions.lastHookAt = (id) => inbox.lastHookAt(id);
|
|
3994
|
+
const sbx = new Sbx(hub);
|
|
3995
|
+
sessions.envFileFor = (id) => existsSync13(sbx.envFile(id)) ? sbx.envFile(id) : null;
|
|
3996
|
+
const mcp = new SbxMcp(hub, (id, file, args, onExit) => sessions.runTerminal(id, file, args, onExit));
|
|
3997
|
+
sessions.onSandboxReady = (id) => {
|
|
3998
|
+
void sbx.syncRules(id);
|
|
3999
|
+
void mcp.attachAll(id);
|
|
4000
|
+
void sbx.refreshPorts(id);
|
|
4001
|
+
};
|
|
4002
|
+
sessions.onSessionDown = /* @__PURE__ */ ((down) => (id) => {
|
|
4003
|
+
down(id);
|
|
4004
|
+
void sbx.refreshPorts(id);
|
|
4005
|
+
})(sessions.onSessionDown);
|
|
4006
|
+
sessions.onSandboxRemoved = (id) => {
|
|
4007
|
+
mcp.forget(id);
|
|
4008
|
+
void sbx.refreshPorts(id);
|
|
4009
|
+
};
|
|
4010
|
+
void sbx.refresh();
|
|
4011
|
+
void mcp.refresh();
|
|
4012
|
+
const planner = new Planner(hub, inbox);
|
|
4013
|
+
planner.actions = new ActionItems(hub, planner);
|
|
4014
|
+
const root = dirname3(dirname3(fileURLToPath(import.meta.url)));
|
|
4015
|
+
const distDir = join13(root, "dist");
|
|
4016
|
+
const uiPort = Number(process.env.HQ_UI_PORT ?? 5173);
|
|
4017
|
+
const uiOrigin = dev ? `http://localhost:${uiPort}` : `http://127.0.0.1:${port}`;
|
|
4018
|
+
if (!dev && !existsSync13(join13(distDir, "index.html"))) log.warn("No built UI found. Run `pnpm build` first, or use `pnpm dev:hq`.");
|
|
4019
|
+
await startHttp({
|
|
4020
|
+
hub,
|
|
4021
|
+
inbox,
|
|
4022
|
+
planner,
|
|
4023
|
+
port,
|
|
4024
|
+
browserToken: uiToken,
|
|
4025
|
+
agentToken: token,
|
|
4026
|
+
distDir: dev ? null : distDir,
|
|
4027
|
+
allowedOrigins: [uiOrigin, `http://127.0.0.1:${uiPort}`, `http://localhost:${port}`, `http://127.0.0.1:${port}`],
|
|
4028
|
+
commands: makeCommands(hub, sessions, planner, inbox, sbx, mcp)
|
|
4029
|
+
});
|
|
4030
|
+
sessions.startPolling();
|
|
4031
|
+
sbx.startApprovalPolling();
|
|
4032
|
+
const github = new GitHubSync(hub, planner, repo.github);
|
|
4033
|
+
github.start();
|
|
4034
|
+
const url = `${uiOrigin}/?token=${uiToken}`;
|
|
4035
|
+
log.info(`
|
|
4036
|
+
|
|
4037
|
+
Setups Works Office is running \u2192 ${url}
|
|
4038
|
+
`);
|
|
4039
|
+
if (!flag("no-open")) {
|
|
4040
|
+
const [opener, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]] : ["xdg-open", [url]];
|
|
4041
|
+
setTimeout(() => void run(opener, args), dev ? 1500 : 0);
|
|
4042
|
+
}
|
|
4043
|
+
const quit = () => {
|
|
4044
|
+
log.info(sessions.backend.persistent ? "stopping HQ \u2014 agent sessions keep running in tmux" : "stopping HQ and its agents \u2014 sandboxes and folders are kept");
|
|
4045
|
+
sessions.shutdown();
|
|
4046
|
+
sbx.stopApprovalPolling();
|
|
4047
|
+
github.stop();
|
|
4048
|
+
process.exit(0);
|
|
4049
|
+
};
|
|
4050
|
+
process.on("SIGINT", quit);
|
|
4051
|
+
process.on("SIGTERM", quit);
|
|
4052
|
+
}
|
|
4053
|
+
main().catch((err) => {
|
|
4054
|
+
if (err?.code === "EADDRINUSE") {
|
|
4055
|
+
log.error(`Port ${arg("port") ?? 7420} is in use \u2014 another HQ may be running. Stop it, or pick another port: npx @setupsworks/office --port 7421`);
|
|
4056
|
+
process.exit(1);
|
|
4057
|
+
}
|
|
4058
|
+
if (err?.code === "EACCES") {
|
|
4059
|
+
const port = arg("port") ?? 7420;
|
|
4060
|
+
log.error(
|
|
4061
|
+
process.platform === "win32" ? `Windows reserved port ${port} (Hyper-V, WSL and Docker reserve port ranges). Pick another: npx @setupsworks/office --port 8420
|
|
4062
|
+
See the reserved ranges with: netsh interface ipv4 show excludedportrange protocol=tcp` : `Not allowed to use port ${port}. Pick another: npx @setupsworks/office --port 8420`
|
|
4063
|
+
);
|
|
4064
|
+
process.exit(1);
|
|
4065
|
+
}
|
|
4066
|
+
log.error(err instanceof Error ? err.message : err);
|
|
4067
|
+
process.exit(1);
|
|
4068
|
+
});
|